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

View file

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

View file

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

View file

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

View file

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

View file

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

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_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:

View file

@ -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...")

View file

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

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:
_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

View file

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

View file

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

View file

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

View file

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

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

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="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

View file

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

View file

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

View file

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

View file

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