feat(network): add selectable privacy-preserving discovery

This commit is contained in:
Alexander Sellite 2026-09-01 10:21:26 -04:00
parent 9cf0c8fc9c
commit 4d31fd1517
26 changed files with 959 additions and 153 deletions

View file

@ -38,11 +38,15 @@ node names, connect persistent and networked state to that data.
network services validate and replicate bounded domains such as fishing, network services validate and replicate bounded domains such as fishing,
sales, shops, item use, profiles, jobs, mail, chat, drawings, time, and weather. sales, shops, item use, profiles, jobs, mail, chat, drawings, time, and weather.
`DiscoveryClient` is an optional directory layer beside `NetworkSession`. An `DiscoveryClient` is an optional directory layer beside `NetworkSession`. A
open host may publish a short-lived room lease, and the shared Join Game page player host cycles its discovery control through `private`, `direct`, and
may browse compatible leases before handing the selected address back to the `relay`. Private leaves the room unlisted, direct discovery discloses the host
existing direct ENet connection flow. The directory does not carry gameplay route only after an explicit Join, and relay discovery allocates a UDP privacy
traffic or become a gameplay authority. Its base URL comes from 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 `network/discovery/base_url`, with `straywild_DISCOVERY_URL` available as a
development/deployment override. 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 short-lived friend presence and deliver invitations only while both games are
online. It stores hashed capability identifiers rather than identity online. It stores hashed capability identifiers rather than identity
fingerprints or a complete friend graph, keeps no durable social records, and fingerprints or a complete friend graph, keeps no durable social records, and
provides no offline delivery. Direct joins still use the existing verified provides no offline delivery. Public joins still use the existing verified
public-room and ENet connection path. 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 The host is authoritative. Clients submit requests or evidence; the host
derives trusted context from registered peers, authoritative regions, and derives trusted context from registered peers, authoritative regions, and

View file

@ -1,13 +1,18 @@
straywild straywild
v0.20.2-alpha v0.20.3-alpha
Alpha 0.20.2 Alpha 0.20.3
Thank you for supporting this private testing release. Thank you for supporting this private testing release.
straywild is an early multiplayer fishing game. This alpha includes local straywild is an early multiplayer fishing game. This alpha includes local
progression, player-hosted and dedicated multiplayer sessions, public room progression, player-hosted and dedicated multiplayer sessions, public room
discovery, and direct UDP connections. Expect rough edges and please keep a discovery, player-selected direct or privacy-relayed hosting, and optional
backup of any save or identity data you care about. 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 INSTALLATION
@ -115,8 +120,9 @@ Please include:
KNOWN ALPHA LIMITATIONS KNOWN ALPHA LIMITATIONS
- Public sessions remain direct connections; restrictive NAT or carrier-grade - Direct public sessions may fail behind restrictive NAT or carrier-grade
networks may prevent some players from joining. 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. - Multiplayer, dedicated-server deployment, UI, and balance are still alpha.
- Some fish and character artwork remains temporary. - Some fish and character artwork remains temporary.
- RV exteriors and decor use temporary prototype geometry and presentation. - RV exteriors and decor use temporary prototype geometry and presentation.

View file

@ -9,7 +9,7 @@ custom_features=""
export_filter="all_resources" export_filter="all_resources"
include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt"
exclude_filter="builds/*,scripts/*" 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() patches=PackedStringArray()
encryption_include_filters="" encryption_include_filters=""
encryption_exclude_filters="" encryption_exclude_filters=""
@ -35,11 +35,11 @@ application/modify_resources=true
application/icon="res://art/exported/system_icons/straywild.ico" application/icon="res://art/exported/system_icons/straywild.ico"
application/console_wrapper_icon="" application/console_wrapper_icon=""
application/icon_interpolation=0 application/icon_interpolation=0
application/file_version="0.20.2.0" application/file_version="0.20.3.0"
application/product_version="0.20.2.0" application/product_version="0.20.3.0"
application/company_name="Woofmeow" application/company_name="Woofmeow"
application/product_name="straywild" 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/copyright="Copyright © 2026 Woofmeow"
application/trademarks="straywild and Woofmeow branding is reserved; see TRADEMARKS.md" application/trademarks="straywild and Woofmeow branding is reserved; see TRADEMARKS.md"
application/export_angle=0 application/export_angle=0
@ -62,7 +62,7 @@ custom_features=""
export_filter="all_resources" export_filter="all_resources"
include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt"
exclude_filter="builds/*,scripts/*" 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() patches=PackedStringArray()
encryption_include_filters="" encryption_include_filters=""
encryption_exclude_filters="" encryption_exclude_filters=""
@ -92,7 +92,7 @@ custom_features=""
export_filter="all_resources" export_filter="all_resources"
include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt"
exclude_filter="builds/*,scripts/*" 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() patches=PackedStringArray()
encryption_include_filters="" encryption_include_filters=""
encryption_exclude_filters="" encryption_exclude_filters=""
@ -108,8 +108,8 @@ custom_template/release=""
application/bundle_identifier="io.woofmeow.netfishing" application/bundle_identifier="io.woofmeow.netfishing"
application/icon="res://art/exported/system_icons/straywild_1024.png" application/icon="res://art/exported/system_icons/straywild_1024.png"
application/icon_interpolation=0 application/icon_interpolation=0
application/short_version="0.20.2" application/short_version="0.20.3"
application/version="0.20.2" application/version="0.20.3"
application/architecture="universal" application/architecture="universal"
codesign/enable=false codesign/enable=false
notarization/enable=false notarization/enable=false
@ -125,7 +125,7 @@ custom_features=""
export_filter="all_resources" export_filter="all_resources"
include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt"
exclude_filter="builds/*,scripts/*,tests/*" 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() patches=PackedStringArray()
encryption_include_filters="" encryption_include_filters=""
encryption_exclude_filters="" encryption_exclude_filters=""
@ -155,7 +155,7 @@ custom_features=""
export_filter="all_resources" export_filter="all_resources"
include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt"
exclude_filter="builds/*,scripts/*" 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() patches=PackedStringArray()
encryption_include_filters="" encryption_include_filters=""
encryption_exclude_filters="" encryption_exclude_filters=""
@ -177,7 +177,7 @@ architectures/arm64-v8a=true
architectures/x86=false architectures/x86=false
architectures/x86_64=false architectures/x86_64=false
version/code=200200 version/code=200200
version/name="v0.20.2-alpha" version/name="v0.20.3-alpha"
package/unique_name="io.woofmeow.netfishing" package/unique_name="io.woofmeow.netfishing"
package/name="straywild" package/name="straywild"
package/signed=true package/signed=true
@ -214,7 +214,7 @@ custom_features=""
export_filter="all_resources" export_filter="all_resources"
include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt"
exclude_filter="builds/*,scripts/*" 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() patches=PackedStringArray()
encryption_include_filters="" encryption_include_filters=""
encryption_exclude_filters="" encryption_exclude_filters=""

View file

@ -2234,9 +2234,15 @@ func _on_return_to_title_requested() -> void:
_show_title_music(true) _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: if _quit_in_progress or _gameplay_started:
return 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() var active_slot: Dictionary = _save_slots.ensure_active_slot()
if not bool(active_slot.get("ok", false)): if not bool(active_slot.get("ok", false)):
_game_ui.get_title_screen().report_network_error( _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.") _network_session.disconnect_session("Preparing direct connection.")
_join_requested_from_title = true _join_requested_from_title = true
_join_requested_from_pause = false _join_requested_from_pause = false
_pending_join_endpoint = endpoint _pending_join_endpoint = (
if not _network_session.join_direct(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 _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: if _quit_in_progress or not _gameplay_started:
return 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(): if not _save_manager.save_world_time_checkpoint():
_game_ui.get_pause_menu().report_network_error( _game_ui.get_pause_menu().report_network_error(
"Could not save progression before leaving this session." "Could not save progression before leaving this session."
@ -2262,20 +2280,36 @@ func _on_pause_join_game_requested(endpoint: String) -> void:
return return
_join_requested_from_pause = true _join_requested_from_pause = true
_join_requested_from_title = false _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() _game_ui.get_pause_menu().close_for_title_transition()
var preserved_public_join: bool = ( var preserved_public_join: bool = (
_discovery.preserve_public_join_for_session_switch() _discovery.preserve_public_join_for_session_switch()
) )
_network_session.disconnect_session("Connecting to another game.") _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: if preserved_public_join:
_discovery.cancel_pending_public_join() _discovery.cancel_pending_public_join()
_handle_failed_session_switch( _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( func _on_friend_request_received(
request_id: String, request_id: String,
fingerprint: String, fingerprint: String,
@ -2335,8 +2369,17 @@ func _show_next_social_prompt() -> void:
elif kind == "friend_invite": elif kind == "friend_invite":
var room: Dictionary = _active_social_prompt.get("room", {}) var room: Dictionary = _active_social_prompt.get("room", {})
var room_name := str(room.get("room_name", "their 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( 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", "join",
"not now", "not now",
) )
@ -2375,16 +2418,16 @@ func _on_social_prompt_declined() -> void:
call_deferred("_show_next_social_prompt") 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: if not _pending_social_invite_join:
return return
var from_gameplay := _pending_social_invite_gameplay var from_gameplay := _pending_social_invite_gameplay
_pending_social_invite_join = false _pending_social_invite_join = false
_pending_social_invite_gameplay = false _pending_social_invite_gameplay = false
if from_gameplay: if from_gameplay:
_on_pause_join_game_requested(endpoint) _on_pause_join_game_requested(route)
else: else:
_on_title_join_game_requested(endpoint) _on_title_join_game_requested(route)
func _on_social_public_join_status_changed( func _on_social_public_join_status_changed(
@ -2412,7 +2455,7 @@ func _on_network_join_authenticated() -> void:
var server_metadata: Dictionary = ( var server_metadata: Dictionary = (
_network_session.get_last_server_metadata() _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( _saved_servers.record_successful_connection(
connected_endpoint, connected_endpoint,
int(server_metadata.get("max_players", 0)), int(server_metadata.get("max_players", 0)),
@ -2455,7 +2498,7 @@ func _on_network_connection_error(message: String) -> void:
var failed_endpoint: ConnectionEndpoint = ( var failed_endpoint: ConnectionEndpoint = (
_network_session.get_current_endpoint() _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( _saved_servers.record_connection_failure(
failed_endpoint, failed_endpoint,
_connection_result_code(message), _connection_result_code(message),

View file

@ -3,22 +3,65 @@ extends RefCounted
enum Kind { enum Kind {
DIRECT, DIRECT,
DISCOVERY_DIRECT,
DISCOVERY_RELAY,
} }
var kind: Kind = Kind.DIRECT var kind: Kind = Kind.DIRECT
var direct_endpoint: ConnectionEndpoint var direct_endpoint: ConnectionEndpoint
var display_description: String = "" var display_description: String = ""
var discovery_room_id: String = ""
static func direct(endpoint: ConnectionEndpoint) -> ConnectionRoute: static func direct(endpoint: ConnectionEndpoint) -> ConnectionRoute:
var route := ConnectionRoute.new() var route := ConnectionRoute.new()
route.kind = Kind.DIRECT route.kind = Kind.DIRECT
route.direct_endpoint = endpoint 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 = ( route.display_description = (
endpoint.normalized_display if endpoint != null else "" cleaned_name if not cleaned_name.is_empty() else "public room"
) )
return route 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: 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

View file

@ -34,11 +34,12 @@ func connect_to_route(route: ConnectionRoute) -> Error:
) )
if error != OK: if error != OK:
transport_error.emit( transport_error.emit(
"Unable to connect to %s." % endpoint.normalized_display "Unable to begin the connection to %s."
% route.display_description
) )
return error return error
_peer = enet_peer _peer = enet_peer
_route_description = endpoint.normalized_display _route_description = route.display_description
return OK return OK

View file

@ -6,7 +6,7 @@ signal browse_status_changed(message: String, is_error: bool)
signal host_settings_changed(room_name: String, discoverable: bool) signal host_settings_changed(room_name: String, discoverable: bool)
signal host_status_changed(message: String, is_error: bool) signal host_status_changed(message: String, is_error: bool)
signal host_state_changed(state: int) 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_status_changed(message: String, is_error: bool)
signal public_join_state_changed(state: int) signal public_join_state_changed(state: int)
signal friend_presence_updated(friends: Array[Dictionary]) 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 = ( const LEGACY_DEDICATED_DEFAULT_ROOM_NAME: String = (
"straywild Dedicated Server" "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 ROOM_NAME_SUFFIX: String = "'s Server"
const MAX_ROOM_NAME_LENGTH: int = 48 const MAX_ROOM_NAME_LENGTH: int = 48
const HEARTBEAT_INTERVAL_SECONDS: float = 15.0 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 TRAVERSAL_POLL_INTERVAL_SECONDS: float = 1.0
const JOIN_PROBE_INTERVAL_SECONDS: float = 0.35 const JOIN_PROBE_INTERVAL_SECONDS: float = 0.35
const TRAVERSAL_PACKET_PREFIX: String = "straywild_TRAVERSAL_V1 " 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_MAPPING_DURATION_SECONDS: int = 3600
const UPNP_RENEW_INTERVAL_SECONDS: float = 2700.0 const UPNP_RENEW_INTERVAL_SECONDS: float = 2700.0
const UPNP_RETRY_INTERVAL_SECONDS: float = 300.0 const UPNP_RETRY_INTERVAL_SECONDS: float = 300.0
@ -90,6 +94,7 @@ var _join_request: HTTPRequest
var _traversal_timer: Timer var _traversal_timer: Timer
var _join_probe_timer: Timer var _join_probe_timer: Timer
var _host_verified: bool = false var _host_verified: bool = false
var _host_connection_preference: String = HOST_CONNECTION_DIRECT
var _traversal_host: String = "" var _traversal_host: String = ""
var _traversal_port: int = 0 var _traversal_port: int = 0
var _host_verification_token: String = "" var _host_verification_token: String = ""
@ -97,6 +102,8 @@ var _join_request_in_flight: bool = false
var _pending_join_endpoint: String = "" var _pending_join_endpoint: String = ""
var _pending_join_token: String = "" var _pending_join_token: String = ""
var _pending_join_room_id: 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 _preserve_pending_join_on_inactive: bool = false
var _upnp_thread: Thread var _upnp_thread: Thread
var _upnp_mapping_in_progress: bool = false var _upnp_mapping_in_progress: bool = false
@ -381,12 +388,25 @@ func get_host_state() -> HostState:
return _host_state 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: func get_public_join_state() -> PublicJoinState:
return _public_join_state return _public_join_state
func configure_dedicated_runtime(room_name: String) -> bool: func configure_dedicated_runtime(room_name: String) -> bool:
_host_settings_persistence_enabled = false _host_settings_persistence_enabled = false
_host_connection_preference = HOST_CONNECTION_DIRECT
return set_room_name(room_name) return set_room_name(room_name)
@ -453,6 +473,49 @@ func set_discoverable(enabled: bool) -> bool:
return true 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: func is_public_join_preparing() -> bool:
return _join_request_in_flight return _join_request_in_flight
@ -480,13 +543,16 @@ func prepare_public_join(room: Dictionary) -> bool:
"You are already hosting this room.", true "You are already hosting this room.", true
) )
return false return false
var endpoint: String = room_endpoint(room)
var room_id: String = str(room.get("room_id", "")).strip_edges() 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) _set_public_join_state(PublicJoinState.ERROR)
return false return false
_pending_join_endpoint = endpoint _pending_join_endpoint = ""
_pending_join_room_id = room_id _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 = "" _pending_join_token = ""
var url: String = "%s/v1/rooms/%s/join-attempts" % [ var url: String = "%s/v1/rooms/%s/join-attempts" % [
_base_url, _base_url,
@ -535,16 +601,6 @@ func request_rooms() -> bool:
return true 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: func is_own_room(room: Dictionary) -> bool:
return ( return (
not _lease_room_id.is_empty() not _lease_room_id.is_empty()
@ -640,6 +696,14 @@ func _host_payload() -> Dictionary:
ProjectSettings.get_setting("application/config/version", "unknown") ProjectSettings.get_setting("application/config/version", "unknown")
), ),
"protocol_version": NetworkProtocol.PROTOCOL_VERSION, "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 else HostState.VERIFYING
) )
_set_host_status( _set_host_status(
"Room is listed publicly." _host_listing_status(room)
if _host_verified if _host_verified
else "Checking the public route…", else "Checking the public route…",
false, false,
@ -706,7 +770,7 @@ func _on_host_request_completed(
else HostState.VERIFYING else HostState.VERIFYING
) )
_set_host_status( _set_host_status(
"Room is listed publicly." _host_listing_status(room)
if _host_verified if _host_verified
else "Checking the public route…", else "Checking the public route…",
false, false,
@ -755,10 +819,33 @@ func _on_join_request_completed(
return return
_pending_join_token = str(response.get("join_token", "")) _pending_join_token = str(response.get("join_token", ""))
_apply_traversal_response(response) _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 ( if (
_pending_join_token.is_empty() _pending_join_token.is_empty()
or _traversal_host.is_empty() or _pending_join_endpoint.is_empty()
or _traversal_port < 1 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) _set_public_join_state(PublicJoinState.ERROR)
public_join_status_changed.emit( public_join_status_changed.emit(
@ -768,7 +855,25 @@ func _on_join_request_completed(
return return
_set_public_join_state(PublicJoinState.CONNECTING) _set_public_join_state(PublicJoinState.CONNECTING)
public_join_status_changed.emit("Connecting…", false) 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: func _on_traversal_timer_timeout() -> void:
@ -856,6 +961,15 @@ func _send_pending_join_probe() -> void:
): ):
_join_probe_timer.stop() _join_probe_timer.stop()
return 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({ _send_traversal_probe({
"kind": "join", "kind": "join",
"room_id": _pending_join_room_id, "room_id": _pending_join_room_id,
@ -1013,10 +1127,24 @@ func _valid_public_room(room: Dictionary) -> bool:
return ( return (
typeof(room.get("room_id")) == TYPE_STRING typeof(room.get("room_id")) == TYPE_STRING
and typeof(room.get("room_name")) == 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 typeof(room.get("game_version")) == TYPE_STRING
and str(room.get("game_version")) == expected_version 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("current_players"), 0, 128)
and _valid_json_integer(room.get("max_players"), 1, 128) and _valid_json_integer(room.get("max_players"), 1, 128)
and int(room["current_players"]) <= int(room["max_players"]) 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." 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: func _discovery_version_mismatch_message(required_version: String) -> String:
var local_version: String = NetworkProtocol.game_version() var local_version: String = NetworkProtocol.game_version()
var rejection: NetworkProtocol.RejectionCode = ( var rejection: NetworkProtocol.RejectionCode = (
@ -1493,6 +1633,8 @@ func _clear_pending_join() -> void:
_pending_join_endpoint = "" _pending_join_endpoint = ""
_pending_join_token = "" _pending_join_token = ""
_pending_join_room_id = "" _pending_join_room_id = ""
_pending_join_room_name = ""
_pending_join_transport = ""
_join_probe_timer.stop() _join_probe_timer.stop()
@ -1571,6 +1713,14 @@ func _load_settings() -> void:
_presence_sharing = bool(config.get_value( _presence_sharing = bool(config.get_value(
"social", "share_presence", false "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: func _save_settings() -> void:
@ -1581,6 +1731,9 @@ func _save_settings() -> void:
config.set_value( config.set_value(
"host", "room_name_uses_default", _room_name_uses_default "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) config.set_value("social", "share_presence", _presence_sharing)
var error: Error = config.save(SETTINGS_PATH) var error: Error = config.save(SETTINGS_PATH)
if error != OK: if error != OK:

View file

@ -485,17 +485,30 @@ func join_direct(endpoint_text: String) -> bool:
if not endpoint.is_valid(): if not endpoint.is_valid():
_fail(endpoint.error_message) _fail(endpoint.error_message)
return false 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 _operation_generation += 1
var generation: int = _operation_generation var generation: int = _operation_generation
_current_route = ConnectionRoute.direct(endpoint) _current_route = route
_set_state( _set_state(
State.CONNECTING, State.CONNECTING,
"Connecting to %s..." % endpoint.normalized_display "Connecting to %s..." % route.display_description
) )
_replace_transport() _replace_transport()
var error: Error = _transport.connect_to_route(_current_route) var error: Error = _transport.connect_to_route(_current_route)
if error != OK: if error != OK:
_fail("Could not begin the direct connection.") _fail("Could not begin the connection.")
return false return false
multiplayer.multiplayer_peer = _transport.get_multiplayer_peer() multiplayer.multiplayer_peer = _transport.get_multiplayer_peer()
_connection_deadline = ( _connection_deadline = (
@ -597,11 +610,23 @@ func get_current_endpoint() -> ConnectionEndpoint:
return ( return (
_current_route.direct_endpoint _current_route.direct_endpoint
if _current_route != null 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 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: func get_last_server_metadata() -> Dictionary:
return { return {
"server_display_name": _last_server_display_name, "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) _pending_server_proof = data.duplicate(true)
_server_identity_fingerprint = fingerprint _server_identity_fingerprint = fingerprint
_server_identity_public_key = public_pem _server_identity_public_key = public_pem
var verification := _server_trust.verify( var verification := (
get_current_endpoint(), fingerprint _server_trust.verify_identity(fingerprint)
if is_current_route_discovery()
else _server_trust.verify(get_current_endpoint(), fingerprint)
) )
if verification == ServerTrustStore.Verification.MATCH: 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() _send_client_identity_proof()
return return
_set_state( _set_state(
@ -1238,9 +1268,12 @@ func receive_server_identity_proof(data: Dictionary) -> void:
"Confirm this server identity before continuing.", "Confirm this server identity before continuing.",
) )
_connection_deadline = 0.0 _connection_deadline = 0.0
var expected := str( var trust_record: Dictionary = (
_server_trust.get_record(get_current_endpoint()).get("fingerprint", "") _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( server_trust_required.emit(
get_current_route_display(), get_current_route_display(),
expected, expected,
@ -1255,11 +1288,15 @@ func resolve_server_trust(accepted: bool) -> void:
if not accepted: if not accepted:
cancel_connection() cancel_connection()
return return
if not _server_trust.trust( var fingerprint := str(_pending_server_proof["server_fingerprint"])
get_current_endpoint(), var trusted := (
str(_pending_server_proof["server_fingerprint"]), _server_trust.trust_identity(fingerprint, get_current_route_display())
"straywild", 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.") _fail_identity("The server identity could not be pinned.")
return return
_set_state(State.AUTHENTICATING, "Authenticating identity...") _set_state(State.AUTHENTICATING, "Authenticating identity...")

View file

@ -196,7 +196,7 @@ func record_successful_connection(
recent = _make_entry(endpoint, SavedServerEntry.Kind.RECENT) recent = _make_entry(endpoint, SavedServerEntry.Kind.RECENT)
_recent_entries.append(recent) _recent_entries.append(recent)
recent["display_name"] = ( 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( _apply_result_metadata(
recent, now, "SUCCESS", server_name, protocol_version, recent, now, "SUCCESS", server_name, protocol_version,
@ -358,7 +358,11 @@ func _make_entry(
var now: int = int(Time.get_unix_time_from_system()) var now: int = int(Time.get_unix_time_from_system())
return { return {
"entry_id": Crypto.new().generate_random_bytes(16).hex_encode(), "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", "route_kind": "DIRECT",
"host": endpoint.host, "host": endpoint.host,
"normalized_host": endpoint.host, "normalized_host": endpoint.host,

View file

@ -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: func get_record(endpoint: ConnectionEndpoint) -> Dictionary:
_ensure_loaded() _ensure_loaded()
if endpoint == null: if endpoint == null:
@ -46,6 +57,11 @@ func get_record(endpoint: ConnectionEndpoint) -> Dictionary:
return Dictionary(_records.get(endpoint.normalized_display, {})).duplicate(true) 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( func trust(
endpoint: ConnectionEndpoint, endpoint: ConnectionEndpoint,
fingerprint: String, fingerprint: String,
@ -74,6 +90,25 @@ func trust(
return _save() 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: func touch(endpoint: ConnectionEndpoint) -> void:
_ensure_loaded() _ensure_loaded()
if endpoint == null: if endpoint == null:
@ -85,6 +120,15 @@ func touch(endpoint: ConnectionEndpoint) -> void:
_save() _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: func _ensure_loaded() -> void:
if _loaded: if _loaded:
return return
@ -108,12 +152,21 @@ func _ensure_loaded() -> void:
continue continue
var record: Dictionary = value var record: Dictionary = value
var endpoint := str(record.get("normalized_endpoint", "")) var endpoint := str(record.get("normalized_endpoint", ""))
var identity_key := str(record.get("identity_key", ""))
var fingerprint := str(record.get("fingerprint", "")) 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) _records[endpoint] = record.duplicate(true)
elif identity_key == _identity_key(fingerprint):
_records[identity_key] = record.duplicate(true)
_expected_hash = PortableFileGuard.hash_file(_store_path) _expected_hash = PortableFileGuard.hash_file(_store_path)
static func _identity_key(fingerprint: String) -> String:
return "identity:%s" % fingerprint
func _save() -> bool: func _save() -> bool:
if _write_blocked: if _write_blocked:
return false return false

View file

@ -11,7 +11,7 @@ config_version=5
[application] [application]
config/name="straywild" config/name="straywild"
config/version="0.20.2-alpha" config/version="0.20.3-alpha"
run/main_scene="res://main/main.tscn" run/main_scene="res://main/main.tscn"
config/features=PackedStringArray("4.7", "GL Compatibility") config/features=PackedStringArray("4.7", "GL Compatibility")
config/icon="res://art/exported/system_icons/straywild_256.png" config/icon="res://art/exported/system_icons/straywild_256.png"

View file

@ -4,14 +4,14 @@ set -euo pipefail
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
readonly PROJECT_ROOT="$(cd -- "${SCRIPT_DIR}/.." && 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 WINDOWS_DIR="${BUILD_ROOT}/windows-x86_64"
readonly LINUX_DIR="${BUILD_ROOT}/linux-x86_64" readonly LINUX_DIR="${BUILD_ROOT}/linux-x86_64"
readonly README_SOURCE="${PROJECT_ROOT}/docs/README-PLAYTEST.txt" readonly README_SOURCE="${PROJECT_ROOT}/docs/README-PLAYTEST.txt"
readonly SOURCE_COMMIT="$(git -C "${PROJECT_ROOT}" rev-parse HEAD)" readonly SOURCE_COMMIT="$(git -C "${PROJECT_ROOT}" rev-parse HEAD)"
readonly SOURCE_URL="https://forge.makearmy.io/woofmeow/straywild" readonly SOURCE_URL="https://forge.makearmy.io/woofmeow/straywild"
readonly WINDOWS_ZIP="${BUILD_ROOT}/straywild-v0.20.2-alpha-windows-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.2-alpha-linux-x86_64.zip" readonly LINUX_ZIP="${BUILD_ROOT}/straywild-v0.20.3-alpha-linux-x86_64.zip"
readonly GODOT_BIN="${GODOT_BIN:-godot}" readonly GODOT_BIN="${GODOT_BIN:-godot}"
if [[ ! -f "${PROJECT_ROOT}/project.godot" ]]; then if [[ ! -f "${PROJECT_ROOT}/project.godot" ]]; then
@ -78,7 +78,7 @@ Source repository: ${SOURCE_URL}
Source revision at build time: ${SOURCE_COMMIT} Source revision at build time: ${SOURCE_COMMIT}
This private testing release is distributed with an exact corresponding 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 If this package includes uncommitted development changes, it must not be
publicly redistributed until the exact corresponding source is also made publicly redistributed until the exact corresponding source is also made

View file

@ -22,6 +22,7 @@ readonly -a QUICK_TESTS=(
"tests/controller_world_interaction_validation.gd" "tests/controller_world_interaction_validation.gd"
"tests/controller_ui_navigation_validation.gd" "tests/controller_ui_navigation_validation.gd"
"tests/dedicated_server_config_validation.gd" "tests/dedicated_server_config_validation.gd"
"tests/discovery_privacy_validation.gd"
"tests/digging_prototype_validation.gd" "tests/digging_prototype_validation.gd"
"tests/equipment_catalog_validation.gd" "tests/equipment_catalog_validation.gd"
"tests/exported_decal_hotfix_validation.gd" "tests/exported_decal_hotfix_validation.gd"

View file

@ -178,16 +178,39 @@ func _run() -> void:
) )
assert(not FileAccess.file_exists(discovery_settings_path)) assert(not FileAccess.file_exists(discovery_settings_path))
assert(discovery.set_room_name("Client Room")) 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( var client_settings: PackedByteArray = FileAccess.get_file_as_bytes(
discovery_settings_path discovery_settings_path
) )
assert(not client_settings.is_empty()) assert(not client_settings.is_empty())
var dedicated_discovery := DiscoveryClient.new() var dedicated_discovery := DiscoveryClient.new()
dedicated_discovery.call("_load_settings") dedicated_discovery.call("_load_settings")
dedicated_discovery.set(
"_host_connection_preference",
DiscoveryClient.HOST_CONNECTION_RELAY,
)
assert( assert(
dedicated_discovery.configure_dedicated_runtime("Headless Room") dedicated_discovery.configure_dedicated_runtime("Headless Room")
) )
assert(dedicated_discovery.get_room_name() == "Headless Room") assert(dedicated_discovery.get_room_name() == "Headless Room")
assert(
dedicated_discovery.get_host_connection_mode()
== DiscoveryClient.HOST_CONNECTION_DIRECT
)
assert( assert(
FileAccess.get_file_as_bytes(discovery_settings_path) FileAccess.get_file_as_bytes(discovery_settings_path)
== client_settings == client_settings
@ -210,16 +233,26 @@ func _run() -> void:
var listed_room := { var listed_room := {
"room_id": "empty-dedicated-room", "room_id": "empty-dedicated-room",
"room_name": "Empty Dedicated Room", "room_name": "Empty Dedicated Room",
"address": "203.0.113.10",
"port": 7777,
"current_players": 0, "current_players": 0,
"max_players": 8, "max_players": 8,
"game_version": str(ProjectSettings.get_setting( "game_version": str(ProjectSettings.get_setting(
"application/config/version", "unknown" "application/config/version", "unknown"
)), )),
"protocol_version": NetworkProtocol.PROTOCOL_VERSION, "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(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) var incompatible_room: Dictionary = listed_room.duplicate(true)
incompatible_room["game_version"] = "0.0.0-alpha" incompatible_room["game_version"] = "0.0.0-alpha"
assert(not bool(discovery.call("_valid_public_room", incompatible_room))) assert(not bool(discovery.call("_valid_public_room", incompatible_room)))

View file

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

View file

@ -0,0 +1 @@
uid://dmtlh1kpm7bl1

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<path d="M4 16c3.2-5 7.2-7.5 12-7.5 4.8 0 8.8 2.5 12 7.5-3.2 5-7.2 7.5-12 7.5C11.2 23.5 7.2 21 4 16Z" fill="none" stroke="#062735" stroke-width="2.5" stroke-linejoin="round"/>
<path d="M7 5 25 27" fill="none" stroke="#062735" stroke-width="3" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 363 B

View file

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

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<path d="M3 16c3.4-5.3 7.8-8 13-8s9.6 2.7 13 8c-3.4 5.3-7.8 8-13 8S6.4 21.3 3 16Z" fill="none" stroke="#062735" stroke-width="2.5" stroke-linejoin="round"/>
<circle cx="16" cy="16" r="4.5" fill="none" stroke="#062735" stroke-width="2.5"/>
</svg>

After

Width:  |  Height:  |  Size: 334 B

View file

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

View file

@ -1,8 +1,8 @@
class_name JoinGamePage class_name JoinGamePage
extends Control extends Control
signal join_requested(endpoint: String) signal join_requested(route: ConnectionRoute)
signal join_confirmation_requested(endpoint: String) signal join_confirmation_requested(endpoint: String, relayed: bool)
signal back_requested signal back_requested
enum Mode { enum Mode {
@ -17,14 +17,23 @@ const DISCOVERY_REFRESH_SECONDS: float = 8.0
const ControllerFocusNavigationType = preload( const ControllerFocusNavigationType = preload(
"res://ui/controller_focus_navigation.gd" "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 = ( const ADDRESS_FORMAT_HELP: String = (
"Hostname, IPv4, or [IPv6]. Port 7777 is used when omitted; " "Hostname, IPv4, or [IPv6]. Port 7777 is used when omitted; "
+ "include the port shown by a host when it differs." + "include the port shown by a host when it differs."
) )
const DIRECT_WORKFLOW_HELP: String = ( const DIRECT_WORKFLOW_HELP: String = (
"%s\nJoin Now connects once; Save Server stores this address locally." "Direct connections expose your public IP address to the remote host. "
% ADDRESS_FORMAT_HELP + "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 @onready var _main_panel: PanelContainer = %MainPanel
@ -33,6 +42,9 @@ const DIRECT_WORKFLOW_HELP: String = (
@onready var _list_content: Control = %ListContent @onready var _list_content: Control = %ListContent
@onready var _list_title: Label = %ListTitle @onready var _list_title: Label = %ListTitle
@onready var _online_controls: Control = %OnlineControls @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 _presence_button: Button = %PresenceButton
@onready var _room_open_button: Button = %RoomOpenButton @onready var _room_open_button: Button = %RoomOpenButton
@onready var _room_listing_button: Button = %RoomListingButton @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_endpoint: String = ""
var _pending_confirmation_room: Dictionary = {} var _pending_confirmation_room: Dictionary = {}
var _owns_pending_public_join: bool = false var _owns_pending_public_join: bool = false
var _addresses_visible: bool = false
func _ready() -> void: func _ready() -> void:
@ -96,6 +109,9 @@ func _ready() -> void:
_presence_button.pressed.connect(_on_presence_pressed) _presence_button.pressed.connect(_on_presence_pressed)
_room_open_button.pressed.connect(_on_room_open_pressed) _room_open_button.pressed.connect(_on_room_open_pressed)
_room_listing_button.pressed.connect(_on_room_listing_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) _refresh_button.pressed.connect(_request_discovery_refresh)
_join_button.pressed.connect(_request_join) _join_button.pressed.connect(_request_join)
_save_button.pressed.connect(_on_save_pressed) _save_button.pressed.connect(_on_save_pressed)
@ -310,6 +326,7 @@ func setup(
func open_page(preserved_endpoint: String = "") -> void: func open_page(preserved_endpoint: String = "") -> void:
_addresses_visible = false
if not preserved_endpoint.is_empty(): if not preserved_endpoint.is_empty():
_address.text = preserved_endpoint _address.text = preserved_endpoint
elif _address.text.is_empty(): elif _address.text.is_empty():
@ -368,7 +385,11 @@ func confirm_pending_join() -> bool:
return false return false
return true return true
_set_status("Connecting…") _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 return true
@ -405,7 +426,7 @@ func _default_mode_status(mode: Mode) -> String:
Mode.FRIENDS: Mode.FRIENDS:
return "friend status is live and not stored by discovery" return "friend status is live and not stored by discovery"
Mode.DIRECT: Mode.DIRECT:
return "direct connection • default port 7777" return "direct connection • your public IP is visible to the host"
Mode.SAVED: Mode.SAVED:
return "saved servers are stored on this device" return "saved servers are stored on this device"
Mode.RECENT: Mode.RECENT:
@ -447,18 +468,26 @@ func _request_join() -> void:
if _discovery == null: if _discovery == null:
_set_status("Could not prepare the public connection.", true) _set_status("Could not prepare the public connection.", true)
return return
endpoint_text = _discovery.room_endpoint(room)
discovery_room = room.duplicate(true) discovery_room = room.duplicate(true)
elif _mode != Mode.DIRECT and _selected_entry != null: elif _mode != Mode.DIRECT and _selected_entry != null:
endpoint_text = _selected_entry.normalized_endpoint endpoint_text = _selected_entry.normalized_endpoint
var endpoint: ConnectionEndpoint = EndpointParser.parse(endpoint_text) var endpoint: ConnectionEndpoint
if not endpoint.is_valid(): if discovery_room.is_empty():
_set_status(endpoint.error_message, true) endpoint = EndpointParser.parse(endpoint_text)
return if not endpoint.is_valid():
_set_status(endpoint.error_message, true)
return
if _gameplay_context: 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 _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 return
if not discovery_room.is_empty(): if not discovery_room.is_empty():
_owns_pending_public_join = true _owns_pending_public_join = true
@ -468,22 +497,20 @@ func _request_join() -> void:
return return
_set_status("Connecting…") _set_status("Connecting…")
_address.text = endpoint.normalized_display _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: if not _owns_pending_public_join:
return return
_owns_pending_public_join = false _owns_pending_public_join = false
if _mode not in [Mode.DISCOVER, Mode.FRIENDS] or not is_visible_in_tree(): if _mode not in [Mode.DISCOVER, Mode.FRIENDS] or not is_visible_in_tree():
return return
var endpoint: ConnectionEndpoint = EndpointParser.parse(endpoint_text) if route == null or not route.is_valid() or not route.is_discovery_join():
if not endpoint.is_valid(): _set_status("Could not reach this room.", true)
_set_status("Discovery returned an invalid room address.", true)
return return
_address.text = endpoint.normalized_display
_set_status("Connecting…") _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: func _on_public_join_status_changed(message: String, is_error: bool) -> void:
@ -518,7 +545,7 @@ func _on_save_pressed() -> void:
) )
if existing != null: if existing != null:
_set_status( _set_status(
"Already saved locally as “%s”." % existing.display_name, "Already saved locally as “%s”." % _display_entry_name(existing),
true, true,
) )
_set_mode(Mode.SAVED) _set_mode(Mode.SAVED)
@ -530,7 +557,7 @@ func _on_save_pressed() -> void:
if _mode == Mode.RECENT if _mode == Mode.RECENT
and _selected_entry != null and _selected_entry != null
and not _selected_entry.last_observed_server_name.is_empty() and not _selected_entry.last_observed_server_name.is_empty()
else endpoint.host else "Saved Server"
) )
_name_entry_active = true _name_entry_active = true
_name_edit.show() _name_edit.show()
@ -559,7 +586,7 @@ func _commit_name_entry() -> void:
_set_status( _set_status(
"Server updated." "Server updated."
if was_editing if was_editing
else "Saved locally as “%s”." % saved.display_name else "Saved locally as “%s”." % _display_entry_name(saved)
) )
_clear_edit_state() _clear_edit_state()
_mode = Mode.SAVED _mode = Mode.SAVED
@ -572,7 +599,17 @@ func _on_edit_pressed() -> void:
return return
_editing_entry_id = _selected_entry.entry_id _editing_entry_id = _selected_entry.entry_id
_address.text = _selected_entry.normalized_endpoint _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_entry_active = true
_name_edit.show() _name_edit.show()
_save_button.text = "save" _save_button.text = "save"
@ -789,8 +826,8 @@ func _refresh_entries() -> void:
"%s%s%s%s" "%s%s%s%s"
% [ % [
prefix, prefix,
entry.display_name, _display_entry_name(entry),
entry.normalized_endpoint, _display_endpoint(entry.normalized_endpoint),
count, count,
] ]
) )
@ -822,6 +859,7 @@ func _refresh() -> void:
var list_content_visible: bool = not direct_content_visible var list_content_visible: bool = not direct_content_visible
_direct_content.visible = direct_content_visible _direct_content.visible = direct_content_visible
_list_content.visible = list_content_visible _list_content.visible = list_content_visible
_refresh_address_privacy_controls()
_list_title.text = _current_list_title() _list_title.text = _current_list_title()
_refresh_online_controls(discovery_mode, friends_mode, connecting) _refresh_online_controls(discovery_mode, friends_mode, connecting)
_address.visible = direct or _name_entry_active _address.visible = direct or _name_entry_active
@ -978,8 +1016,10 @@ func _refresh_online_controls(
if not local_host: if not local_host:
return return
var room_open: bool = _network_session.is_open_host() var room_open: bool = _network_session.is_open_host()
var room_listed: bool = ( var host_discovery_mode: String = (
_discovery != null and _discovery.is_discoverable() _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.text = "room: open" if room_open else "room: closed"
_room_open_button.disabled = connecting _room_open_button.disabled = connecting
@ -988,9 +1028,7 @@ func _refresh_online_controls(
if room_open if room_open
else "Open this room so other players can connect." else "Open this room so other players can connect."
) )
_room_listing_button.text = ( _room_listing_button.text = "discovery | %s" % host_discovery_mode
"listing: on" if room_listed else "listing: off"
)
_room_listing_button.disabled = ( _room_listing_button.disabled = (
connecting connecting
or _discovery == null or _discovery == null
@ -998,9 +1036,14 @@ func _refresh_online_controls(
or not room_open or not room_open
) )
_room_listing_button.tooltip_text = ( _room_listing_button.tooltip_text = (
"Remove this room from the public room browser." "Stop discovery listing. Existing relay connections remain active "
if room_listed + "until those players disconnect."
else "List this open room in the public room browser." 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() if room_open and _discovery != null and _discovery.is_configured()
else "Open the room before enabling its public listing." else "Open the room before enabling its public listing."
if _discovery != null and _discovery.is_configured() if _discovery != null and _discovery.is_configured()
@ -1025,6 +1068,7 @@ func _configure_controller_navigation() -> void:
online_controls.append(control) online_controls.append(control)
var content_controls: Array[Control] = [] var content_controls: Array[Control] = []
for control: Control in [ for control: Control in [
_address_visibility_button,
_address, _address,
_name_edit, _name_edit,
_server_list, _server_list,
@ -1056,6 +1100,7 @@ func _configure_controller_navigation() -> void:
_presence_button, _presence_button,
_room_open_button, _room_open_button,
_room_listing_button, _room_listing_button,
_address_visibility_button,
_address, _address,
_name_edit, _name_edit,
_server_list, _server_list,
@ -1219,8 +1264,8 @@ func _recover_controller_focus(
func _format_entry_details(entry: SavedServerEntry) -> String: func _format_entry_details(entry: SavedServerEntry) -> String:
var parts: Array[String] = [] var parts: Array[String] = []
if _mode == Mode.SAVED: if _mode == Mode.SAVED:
parts.append("Name: %s" % entry.display_name) parts.append("Name: %s" % _display_entry_name(entry))
parts.append("Address: %s" % entry.normalized_endpoint) parts.append("Address: %s" % _display_endpoint(entry.normalized_endpoint))
if _server_trust != null: if _server_trust != null:
var trust := _server_trust.get_record(entry.get_endpoint()) var trust := _server_trust.get_record(entry.get_endpoint())
if trust.is_empty(): if trust.is_empty():
@ -1239,7 +1284,10 @@ func _format_entry_details(entry: SavedServerEntry) -> String:
or entry.last_observed_server_name != entry.display_name 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: if entry.last_observed_max_players > 0:
parts.append( parts.append(
"Players: %d / %d" "Players: %d / %d"
@ -1259,22 +1307,139 @@ func _format_entry_details(entry: SavedServerEntry) -> String:
parts.append( parts.append(
"Last result: %s" % _format_result_code(entry.last_result_code) "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) 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: func _format_discovery_details(room: Dictionary) -> String:
if room.is_empty(): if room.is_empty():
return "Select a public room." return "Select a public room."
var relayed := _room_uses_relay(room)
var lines: Array[String] = [ var lines: Array[String] = [
"Room: %s" % str(room.get("room_name", "Public room")), "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" % [ "Players: %d / %d" % [
int(room.get("current_players", 0)), int(room.get("current_players", 0)),
int(room.get("max_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): if _discovery != null and _discovery.is_own_room(room):
lines.append("This is the room you are currently hosting.") 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(): if not room.is_empty():
var relayed := _room_uses_relay(room)
lines.append("Room: %s" % str(room.get("room_name", "Public 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" % [ lines.append("Players: %d / %d" % [
int(room.get("current_players", 0)), int(room.get("current_players", 0)),
int(room.get("max_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)): elif bool(friend.get("online", false)):
lines.append("This friend is not in a joinable public room.") lines.append("This friend is not in a joinable public room.")
else: 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)) 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: func _request_discovery_refresh() -> void:
if ( if (
_discovery == null _discovery == null
@ -1435,14 +1621,15 @@ func _on_room_open_pressed() -> void:
func _on_room_listing_pressed() -> void: func _on_room_listing_pressed() -> void:
if _discovery == null: if _discovery == null:
return return
var enabling: bool = not _discovery.is_discoverable() if not _discovery.cycle_host_discovery_mode():
if not _discovery.set_discoverable(enabling):
return return
_set_status( match _discovery.get_host_discovery_mode():
"The room is being listed publicly." DiscoveryClient.HOST_CONNECTION_DIRECT:
if enabling _set_status("Discovery is listing this room as a direct connection.")
else "The room is no longer listed publicly." DiscoveryClient.HOST_CONNECTION_RELAY:
) _set_status("Discovery is listing this room through the privacy relay.")
_:
_set_status("Discovery listing is private.")
_refresh() _refresh()

View file

@ -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="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="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="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="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"] [node name="JoinGamePage" type="Control"]
visible = false visible = false
@ -117,6 +119,33 @@ theme_override_constants/margin_bottom = 20
layout_mode = 2 layout_mode = 2
theme_override_constants/separation = 12 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"] [node name="PageStack" type="Control" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout"]
layout_mode = 2 layout_mode = 2
size_flags_vertical = 3 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" placeholder_text = "example.net or 192.168.1.50:7777"
alignment = 1 alignment = 1
max_length = 300 max_length = 300
secret = true
secret_character = "*"
[node name="AddressHelper" type="Label" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/PageStack/DirectContent"] [node name="AddressHelper" type="Label" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/PageStack/DirectContent"]
unique_name_in_owner = true unique_name_in_owner = true

View file

@ -25,7 +25,7 @@ signal return_to_title_requested
signal reset_progress_requested signal reset_progress_requested
signal quit_requested signal quit_requested
signal menu_visibility_changed(is_open: bool) signal menu_visibility_changed(is_open: bool)
signal join_game_requested(endpoint: String) signal join_game_requested(route: ConnectionRoute)
enum ConfirmationAction { enum ConfirmationAction {
NONE, NONE,
@ -240,18 +240,29 @@ func _close_join_game() -> void:
_begin_root_entry(true) _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: if _action_in_progress or _root_transition_active:
_join_game_page.cancel_pending_join_confirmation() _join_game_page.cancel_pending_join_confirmation()
return return
_confirmation_action = ConfirmationAction.JOIN_ANOTHER _confirmation_action = ConfirmationAction.JOIN_ANOTHER
_confirmation_returns_to_join_game = true _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( _confirmation_page.configure(
( "join another game?\n\n"
"join another game?\n\n" + "your progression will be saved first. current players will be "
+ "your progression will be saved first. current players will be " + "disconnected if you are hosting.\n\n"
+ "disconnected if you are hosting." + privacy_message,
),
"save and join", "save and join",
"cancel", "cancel",
BubbleConfirmationPageType.InitialFocus.CONFIRM, BubbleConfirmationPageType.InitialFocus.CONFIRM,
@ -261,8 +272,8 @@ func _on_join_confirmation_requested(_endpoint: String) -> void:
_show_confirmation() _show_confirmation()
func _on_confirmed_join_requested(endpoint: String) -> void: func _on_confirmed_join_requested(route: ConnectionRoute) -> void:
join_game_requested.emit(endpoint) join_game_requested.emit(route)
func report_network_error(message: String) -> void: func report_network_error(message: String) -> void:

View file

@ -401,7 +401,10 @@ func _refresh_host_settings() -> void:
if not _room_name_edit.has_focus(): if not _room_name_edit.has_focus():
_room_name_edit.text = _discovery.get_room_name() _room_name_edit.text = _discovery.get_room_name()
var is_open: bool = _service.is_open_host() 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( _set_host_toggle_state(
_online_toggle, _online_toggle,
_online_state_label, _online_state_label,
@ -411,7 +414,7 @@ func _refresh_host_settings() -> void:
_set_host_toggle_state( _set_host_toggle_state(
_discoverable_toggle, _discoverable_toggle,
_discoverable_state_label, _discoverable_state_label,
"ON" if is_discoverable else "OFF", discovery_mode.to_upper(),
is_discoverable, is_discoverable,
) )
_online_toggle.disabled = not _service.is_local_host() _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.disabled = not can_publish
_discoverable_toggle.tooltip_text = ( _discoverable_toggle.tooltip_text = (
"Stop advertising this game in the public room browser." "Stop discovery listing. Existing relayed players remain connected."
if is_discoverable if discovery_mode == DiscoveryClient.HOST_CONNECTION_RELAY
else "Advertise this open game in the public room browser." 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 if can_publish
else "Open the game before enabling discovery." else "Open the game before enabling discovery."
if _discovery.is_configured() if _discovery.is_configured()
@ -460,7 +465,7 @@ func _on_online_pressed() -> void:
func _on_discoverable_pressed() -> void: func _on_discoverable_pressed() -> void:
if _discovery == null: if _discovery == null:
return return
_discovery.set_discoverable(not _discovery.is_discoverable()) _discovery.cycle_host_discovery_mode()
_refresh_host_settings() _refresh_host_settings()
@ -474,7 +479,7 @@ func _on_host_settings_changed(
_set_host_toggle_state( _set_host_toggle_state(
_discoverable_toggle, _discoverable_toggle,
_discoverable_state_label, _discoverable_state_label,
"ON" if discoverable else "OFF", _discovery.get_host_discovery_mode().to_upper(),
discoverable, discoverable,
) )
_refresh_host_settings() _refresh_host_settings()

View file

@ -67,7 +67,7 @@ signal new_slot_requested(
duplicate_source_slot_id: String, duplicate_source_slot_id: String,
) )
signal quit_requested signal quit_requested
signal join_game_requested(endpoint: String) signal join_game_requested(route: ConnectionRoute)
enum ConfirmationAction { enum ConfirmationAction {
NONE, NONE,

View file

@ -213,7 +213,7 @@ unique_name_in_owner = true
layout_mode = 2 layout_mode = 2
theme_override_colors/font_color = Color(0.682, 0.733, 0.761, 1) theme_override_colors/font_color = Color(0.682, 0.733, 0.761, 1)
theme_override_font_sizes/font_size = 22 theme_override_font_sizes/font_size = 22
text = "v0.20.2-alpha" text = "v0.20.3-alpha"
horizontal_alignment = 1 horizontal_alignment = 1
[node name="Spacer" type="Control" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent"] [node name="Spacer" type="Control" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent"]