2026-08-10 10:04:05 -04:00
|
|
|
class_name DiscoveryClient
|
|
|
|
|
extends Node
|
|
|
|
|
|
|
|
|
|
signal rooms_updated(rooms: Array[Dictionary])
|
|
|
|
|
signal browse_status_changed(message: String, is_error: bool)
|
|
|
|
|
signal host_settings_changed(room_name: String, discoverable: bool)
|
|
|
|
|
signal host_status_changed(message: String, is_error: bool)
|
2026-08-11 10:37:03 -04:00
|
|
|
signal host_state_changed(state: int)
|
2026-08-10 19:49:06 -04:00
|
|
|
signal public_join_prepared(endpoint: String)
|
|
|
|
|
signal public_join_status_changed(message: String, is_error: bool)
|
2026-08-11 10:37:03 -04:00
|
|
|
signal public_join_state_changed(state: int)
|
feat: expand progression and multiplayer systems
Add named save slots, progression import/export, and a unified play flow. Add live friend requests, presence, invitations, and relationship controls without durable discovery-server social storage. Advance the network protocol with isolated channels, movement reconciliation, late-join recovery, fishing replication, and animation synchronization. Preserve per-species catch totals, refine generated-world startup and water recovery, and complete the related input and interface improvements.
2026-08-23 20:48:38 -04:00
|
|
|
signal friend_presence_updated(friends: Array[Dictionary])
|
|
|
|
|
signal presence_sharing_changed(enabled: bool)
|
|
|
|
|
signal social_status_changed(message: String, is_error: bool)
|
|
|
|
|
signal friend_invite_received(
|
|
|
|
|
fingerprint: String,
|
|
|
|
|
display_name: String,
|
|
|
|
|
room: Dictionary,
|
|
|
|
|
)
|
|
|
|
|
signal friend_invite_finished(success: bool, message: String)
|
2026-08-10 10:04:05 -04:00
|
|
|
|
|
|
|
|
const BASE_URL_SETTING: String = "network/discovery/base_url"
|
2026-08-25 17:14:12 -04:00
|
|
|
const BASE_URL_ENVIRONMENT: String = "straywild_DISCOVERY_URL"
|
2026-08-25 22:18:20 -04:00
|
|
|
const LEGACY_BASE_URL_ENVIRONMENT: String = "NETFISHING_DISCOVERY_URL"
|
2026-08-10 10:04:05 -04:00
|
|
|
const SETTINGS_PATH: String = "user://network_discovery.cfg"
|
2026-08-11 16:35:33 -04:00
|
|
|
const DEFAULT_ROOM_NAME: String = "Player's Server"
|
2026-08-25 17:14:12 -04:00
|
|
|
const LEGACY_DEFAULT_ROOM_NAME: String = "straywild Room"
|
2026-08-11 16:35:33 -04:00
|
|
|
const LEGACY_DEDICATED_DEFAULT_ROOM_NAME: String = (
|
2026-08-25 17:14:12 -04:00
|
|
|
"straywild Dedicated Server"
|
2026-08-11 16:35:33 -04:00
|
|
|
)
|
|
|
|
|
const ROOM_NAME_SUFFIX: String = "'s Server"
|
2026-08-10 10:04:05 -04:00
|
|
|
const MAX_ROOM_NAME_LENGTH: int = 48
|
|
|
|
|
const HEARTBEAT_INTERVAL_SECONDS: float = 15.0
|
|
|
|
|
const REQUEST_TIMEOUT_SECONDS: float = 8.0
|
2026-08-10 19:49:06 -04:00
|
|
|
const TRAVERSAL_POLL_INTERVAL_SECONDS: float = 1.0
|
|
|
|
|
const JOIN_PROBE_INTERVAL_SECONDS: float = 0.35
|
2026-08-25 17:14:12 -04:00
|
|
|
const TRAVERSAL_PACKET_PREFIX: String = "straywild_TRAVERSAL_V1 "
|
2026-08-10 19:49:06 -04:00
|
|
|
const UPNP_MAPPING_DURATION_SECONDS: int = 3600
|
2026-08-11 10:37:03 -04:00
|
|
|
const UPNP_RENEW_INTERVAL_SECONDS: float = 2700.0
|
|
|
|
|
const UPNP_RETRY_INTERVAL_SECONDS: float = 300.0
|
feat: expand progression and multiplayer systems
Add named save slots, progression import/export, and a unified play flow. Add live friend requests, presence, invitations, and relationship controls without durable discovery-server social storage. Advance the network protocol with isolated channels, movement reconciliation, late-join recovery, fishing replication, and animation synchronization. Preserve per-species catch totals, refine generated-world startup and water recovery, and complete the related input and interface improvements.
2026-08-23 20:48:38 -04:00
|
|
|
const SOCIAL_POLL_INTERVAL_SECONDS: float = 5.0
|
2026-08-11 10:37:03 -04:00
|
|
|
|
|
|
|
|
enum HostState {
|
|
|
|
|
UNAVAILABLE,
|
|
|
|
|
CLOSED,
|
|
|
|
|
PRIVATE,
|
|
|
|
|
OPENING_PORT,
|
|
|
|
|
REGISTERING,
|
|
|
|
|
VERIFYING,
|
|
|
|
|
PUBLIC,
|
|
|
|
|
ERROR,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
enum PublicJoinState {
|
|
|
|
|
IDLE,
|
|
|
|
|
REQUESTING_ROUTE,
|
|
|
|
|
CONNECTING,
|
|
|
|
|
ERROR,
|
|
|
|
|
}
|
2026-08-10 10:04:05 -04:00
|
|
|
|
|
|
|
|
enum HostRequestKind {
|
|
|
|
|
NONE,
|
|
|
|
|
CREATE,
|
|
|
|
|
UPDATE,
|
|
|
|
|
DELETE,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var _session: NetworkSession
|
feat: expand progression and multiplayer systems
Add named save slots, progression import/export, and a unified play flow. Add live friend requests, presence, invitations, and relationship controls without durable discovery-server social storage. Advance the network protocol with isolated channels, movement reconciliation, late-join recovery, fishing replication, and animation synchronization. Preserve per-species catch totals, refine generated-world startup and water recovery, and complete the related input and interface improvements.
2026-08-23 20:48:38 -04:00
|
|
|
var _relationships: PlayerRelationshipStore
|
2026-08-10 10:04:05 -04:00
|
|
|
var _base_url: String = ""
|
|
|
|
|
var _room_name: String = DEFAULT_ROOM_NAME
|
2026-08-11 16:35:33 -04:00
|
|
|
var _room_name_uses_default: bool = true
|
2026-08-11 22:58:33 -04:00
|
|
|
var _host_settings_persistence_enabled: bool = true
|
2026-08-10 10:04:05 -04:00
|
|
|
var _discoverable: bool = false
|
|
|
|
|
var _host_status_message: String = ""
|
|
|
|
|
var _host_status_is_error: bool = false
|
2026-08-11 10:37:03 -04:00
|
|
|
var _host_state: HostState = HostState.CLOSED
|
|
|
|
|
var _public_join_state: PublicJoinState = PublicJoinState.IDLE
|
2026-08-10 10:04:05 -04:00
|
|
|
var _lease_room_id: String = ""
|
|
|
|
|
var _lease_token: String = ""
|
|
|
|
|
var _host_request_kind: HostRequestKind = HostRequestKind.NONE
|
|
|
|
|
var _host_request_in_flight: bool = false
|
|
|
|
|
var _host_sync_queued: bool = false
|
|
|
|
|
var _browse_request_in_flight: bool = false
|
|
|
|
|
var _host_request: HTTPRequest
|
|
|
|
|
var _browse_request: HTTPRequest
|
|
|
|
|
var _heartbeat: Timer
|
2026-08-10 19:49:06 -04:00
|
|
|
var _traversal_request: HTTPRequest
|
|
|
|
|
var _join_request: HTTPRequest
|
|
|
|
|
var _traversal_timer: Timer
|
|
|
|
|
var _join_probe_timer: Timer
|
|
|
|
|
var _host_verified: bool = false
|
|
|
|
|
var _traversal_host: String = ""
|
|
|
|
|
var _traversal_port: int = 0
|
|
|
|
|
var _host_verification_token: String = ""
|
|
|
|
|
var _join_request_in_flight: bool = false
|
|
|
|
|
var _pending_join_endpoint: String = ""
|
|
|
|
|
var _pending_join_token: String = ""
|
|
|
|
|
var _pending_join_room_id: String = ""
|
2026-08-13 22:20:43 -04:00
|
|
|
var _preserve_pending_join_on_inactive: bool = false
|
2026-08-10 19:49:06 -04:00
|
|
|
var _upnp_thread: Thread
|
|
|
|
|
var _upnp_mapping_in_progress: bool = false
|
2026-08-11 10:37:03 -04:00
|
|
|
var _upnp_operation_is_renewal: bool = false
|
|
|
|
|
var _upnp_renew_timer: Timer
|
2026-08-10 19:49:06 -04:00
|
|
|
var _upnp: UPNP
|
|
|
|
|
var _upnp_mapped_port: int = 0
|
feat: expand progression and multiplayer systems
Add named save slots, progression import/export, and a unified play flow. Add live friend requests, presence, invitations, and relationship controls without durable discovery-server social storage. Advance the network protocol with isolated channels, movement reconciliation, late-join recovery, fishing replication, and animation synchronization. Preserve per-species catch totals, refine generated-world startup and water recovery, and complete the related input and interface improvements.
2026-08-23 20:48:38 -04:00
|
|
|
var _presence_sharing: bool = false
|
|
|
|
|
var _social_timer: Timer
|
|
|
|
|
var _presence_request: HTTPRequest
|
|
|
|
|
var _presence_query_request: HTTPRequest
|
|
|
|
|
var _invitation_poll_request: HTTPRequest
|
|
|
|
|
var _invitation_send_request: HTTPRequest
|
|
|
|
|
var _friend_presence: Dictionary[String, Dictionary] = {}
|
|
|
|
|
var _published_presence_tokens: PackedStringArray = PackedStringArray()
|
|
|
|
|
var _pending_presence_online: bool = false
|
|
|
|
|
var _pending_presence_tokens: PackedStringArray = PackedStringArray()
|
|
|
|
|
var _pending_invite_fingerprint: String = ""
|
|
|
|
|
var _joined_public_room_id: String = ""
|
2026-08-10 10:04:05 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
func _ready() -> void:
|
|
|
|
|
_host_request = HTTPRequest.new()
|
|
|
|
|
_host_request.name = "HostLeaseRequest"
|
|
|
|
|
_host_request.timeout = REQUEST_TIMEOUT_SECONDS
|
|
|
|
|
add_child(_host_request)
|
|
|
|
|
_host_request.request_completed.connect(_on_host_request_completed)
|
|
|
|
|
|
|
|
|
|
_browse_request = HTTPRequest.new()
|
|
|
|
|
_browse_request.name = "RoomBrowseRequest"
|
|
|
|
|
_browse_request.timeout = REQUEST_TIMEOUT_SECONDS
|
|
|
|
|
add_child(_browse_request)
|
|
|
|
|
_browse_request.request_completed.connect(_on_browse_request_completed)
|
|
|
|
|
|
2026-08-10 19:49:06 -04:00
|
|
|
_traversal_request = HTTPRequest.new()
|
|
|
|
|
_traversal_request.name = "HostTraversalRequest"
|
|
|
|
|
_traversal_request.timeout = REQUEST_TIMEOUT_SECONDS
|
|
|
|
|
add_child(_traversal_request)
|
|
|
|
|
_traversal_request.request_completed.connect(
|
|
|
|
|
_on_traversal_request_completed
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
_join_request = HTTPRequest.new()
|
|
|
|
|
_join_request.name = "PublicJoinPreparationRequest"
|
|
|
|
|
_join_request.timeout = REQUEST_TIMEOUT_SECONDS
|
|
|
|
|
add_child(_join_request)
|
|
|
|
|
_join_request.request_completed.connect(_on_join_request_completed)
|
|
|
|
|
|
2026-08-10 10:04:05 -04:00
|
|
|
_heartbeat = Timer.new()
|
|
|
|
|
_heartbeat.name = "HostLeaseHeartbeat"
|
|
|
|
|
_heartbeat.wait_time = HEARTBEAT_INTERVAL_SECONDS
|
|
|
|
|
_heartbeat.one_shot = false
|
|
|
|
|
add_child(_heartbeat)
|
2026-08-11 10:37:03 -04:00
|
|
|
_heartbeat.timeout.connect(_on_heartbeat_timeout)
|
|
|
|
|
|
|
|
|
|
_upnp_renew_timer = Timer.new()
|
|
|
|
|
_upnp_renew_timer.name = "HostUPnPRenewal"
|
|
|
|
|
_upnp_renew_timer.one_shot = true
|
|
|
|
|
add_child(_upnp_renew_timer)
|
|
|
|
|
_upnp_renew_timer.timeout.connect(_on_upnp_renew_timer_timeout)
|
|
|
|
|
|
2026-08-10 19:49:06 -04:00
|
|
|
_traversal_timer = Timer.new()
|
|
|
|
|
_traversal_timer.name = "HostTraversalPoll"
|
|
|
|
|
_traversal_timer.wait_time = TRAVERSAL_POLL_INTERVAL_SECONDS
|
|
|
|
|
_traversal_timer.one_shot = false
|
|
|
|
|
add_child(_traversal_timer)
|
|
|
|
|
_traversal_timer.timeout.connect(_on_traversal_timer_timeout)
|
|
|
|
|
|
|
|
|
|
_join_probe_timer = Timer.new()
|
|
|
|
|
_join_probe_timer.name = "PublicJoinTraversalProbe"
|
|
|
|
|
_join_probe_timer.wait_time = JOIN_PROBE_INTERVAL_SECONDS
|
|
|
|
|
_join_probe_timer.one_shot = false
|
|
|
|
|
add_child(_join_probe_timer)
|
|
|
|
|
_join_probe_timer.timeout.connect(_send_pending_join_probe)
|
feat: expand progression and multiplayer systems
Add named save slots, progression import/export, and a unified play flow. Add live friend requests, presence, invitations, and relationship controls without durable discovery-server social storage. Advance the network protocol with isolated channels, movement reconciliation, late-join recovery, fishing replication, and animation synchronization. Preserve per-species catch totals, refine generated-world startup and water recovery, and complete the related input and interface improvements.
2026-08-23 20:48:38 -04:00
|
|
|
|
|
|
|
|
_presence_request = _make_social_request(
|
|
|
|
|
"FriendPresencePublish", _on_presence_request_completed
|
|
|
|
|
)
|
|
|
|
|
_presence_query_request = _make_social_request(
|
|
|
|
|
"FriendPresenceQuery", _on_presence_query_completed
|
|
|
|
|
)
|
|
|
|
|
_invitation_poll_request = _make_social_request(
|
|
|
|
|
"FriendInvitationPoll", _on_invitation_poll_completed
|
|
|
|
|
)
|
|
|
|
|
_invitation_send_request = _make_social_request(
|
|
|
|
|
"FriendInvitationSend", _on_invitation_send_completed
|
|
|
|
|
)
|
|
|
|
|
_social_timer = Timer.new()
|
|
|
|
|
_social_timer.name = "FriendPresencePoll"
|
|
|
|
|
_social_timer.wait_time = SOCIAL_POLL_INTERVAL_SECONDS
|
|
|
|
|
_social_timer.one_shot = false
|
|
|
|
|
add_child(_social_timer)
|
|
|
|
|
_social_timer.timeout.connect(_refresh_social_state)
|
2026-08-10 10:04:05 -04:00
|
|
|
_load_settings()
|
|
|
|
|
_base_url = _configured_base_url()
|
|
|
|
|
|
|
|
|
|
|
feat: expand progression and multiplayer systems
Add named save slots, progression import/export, and a unified play flow. Add live friend requests, presence, invitations, and relationship controls without durable discovery-server social storage. Advance the network protocol with isolated channels, movement reconciliation, late-join recovery, fishing replication, and animation synchronization. Preserve per-species catch totals, refine generated-world startup and water recovery, and complete the related input and interface improvements.
2026-08-23 20:48:38 -04:00
|
|
|
func setup(
|
|
|
|
|
session: NetworkSession,
|
|
|
|
|
relationships: PlayerRelationshipStore = null,
|
|
|
|
|
) -> void:
|
2026-08-10 10:04:05 -04:00
|
|
|
_session = session
|
feat: expand progression and multiplayer systems
Add named save slots, progression import/export, and a unified play flow. Add live friend requests, presence, invitations, and relationship controls without durable discovery-server social storage. Advance the network protocol with isolated channels, movement reconciliation, late-join recovery, fishing replication, and animation synchronization. Preserve per-species catch totals, refine generated-world startup and water recovery, and complete the related input and interface improvements.
2026-08-23 20:48:38 -04:00
|
|
|
_relationships = relationships
|
2026-08-11 16:35:33 -04:00
|
|
|
if _room_name_uses_default:
|
|
|
|
|
_room_name = _default_room_name(_session.get_local_display_name())
|
|
|
|
|
_save_settings()
|
2026-08-10 10:04:05 -04:00
|
|
|
_session.set_session_display_name(_room_name)
|
feat: expand progression and multiplayer systems
Add named save slots, progression import/export, and a unified play flow. Add live friend requests, presence, invitations, and relationship controls without durable discovery-server social storage. Advance the network protocol with isolated channels, movement reconciliation, late-join recovery, fishing replication, and animation synchronization. Preserve per-species catch totals, refine generated-world startup and water recovery, and complete the related input and interface improvements.
2026-08-23 20:48:38 -04:00
|
|
|
if (
|
|
|
|
|
_relationships != null
|
|
|
|
|
and not _relationships.relationship_changed.is_connected(
|
|
|
|
|
_on_social_relationship_changed
|
|
|
|
|
)
|
|
|
|
|
):
|
|
|
|
|
_relationships.relationship_changed.connect(
|
|
|
|
|
_on_social_relationship_changed
|
|
|
|
|
)
|
2026-08-10 10:04:05 -04:00
|
|
|
if not _session.state_changed.is_connected(_on_session_state_changed):
|
|
|
|
|
_session.state_changed.connect(_on_session_state_changed)
|
|
|
|
|
if not _session.peer_count_changed.is_connected(_on_peer_count_changed):
|
|
|
|
|
_session.peer_count_changed.connect(_on_peer_count_changed)
|
|
|
|
|
if not _session.host_openness_changed.is_connected(_on_host_openness_changed):
|
|
|
|
|
_session.host_openness_changed.connect(_on_host_openness_changed)
|
|
|
|
|
host_settings_changed.emit(_room_name, _discoverable)
|
|
|
|
|
if not is_configured():
|
2026-08-11 10:37:03 -04:00
|
|
|
_set_host_state(HostState.UNAVAILABLE)
|
2026-08-10 10:04:05 -04:00
|
|
|
_set_host_status("Room discovery is not configured in this build.", true)
|
|
|
|
|
elif _session.is_open_host():
|
2026-08-11 10:37:03 -04:00
|
|
|
_set_host_state(HostState.PRIVATE)
|
2026-08-10 10:04:05 -04:00
|
|
|
_set_host_status("Room is open but unlisted.", false)
|
|
|
|
|
else:
|
2026-08-11 10:37:03 -04:00
|
|
|
_set_host_state(HostState.CLOSED)
|
2026-08-10 10:04:05 -04:00
|
|
|
_set_host_status("Open the game before listing it publicly.", false)
|
feat: expand progression and multiplayer systems
Add named save slots, progression import/export, and a unified play flow. Add live friend requests, presence, invitations, and relationship controls without durable discovery-server social storage. Advance the network protocol with isolated channels, movement reconciliation, late-join recovery, fishing replication, and animation synchronization. Preserve per-species catch totals, refine generated-world startup and water recovery, and complete the related input and interface improvements.
2026-08-23 20:48:38 -04:00
|
|
|
if is_configured() and _relationships != null:
|
|
|
|
|
_social_timer.start()
|
|
|
|
|
call_deferred("_refresh_social_state")
|
2026-08-10 10:04:05 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
func is_configured() -> bool:
|
|
|
|
|
return not _base_url.is_empty()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func get_base_url() -> String:
|
|
|
|
|
return _base_url
|
|
|
|
|
|
|
|
|
|
|
feat: expand progression and multiplayer systems
Add named save slots, progression import/export, and a unified play flow. Add live friend requests, presence, invitations, and relationship controls without durable discovery-server social storage. Advance the network protocol with isolated channels, movement reconciliation, late-join recovery, fishing replication, and animation synchronization. Preserve per-species catch totals, refine generated-world startup and water recovery, and complete the related input and interface improvements.
2026-08-23 20:48:38 -04:00
|
|
|
func is_presence_sharing() -> bool:
|
|
|
|
|
return _presence_sharing
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func set_presence_sharing(enabled: bool) -> bool:
|
|
|
|
|
if enabled and (not is_configured() or _relationships == null):
|
|
|
|
|
social_status_changed.emit(
|
|
|
|
|
"Friend presence is not configured in this build.", true
|
|
|
|
|
)
|
|
|
|
|
return false
|
|
|
|
|
if _presence_sharing == enabled:
|
|
|
|
|
return true
|
|
|
|
|
_presence_sharing = enabled
|
|
|
|
|
_save_settings()
|
|
|
|
|
presence_sharing_changed.emit(enabled)
|
|
|
|
|
_refresh_social_state()
|
|
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func get_friend_presence() -> Array[Dictionary]:
|
|
|
|
|
var result: Array[Dictionary] = []
|
|
|
|
|
if _relationships == null:
|
|
|
|
|
return result
|
|
|
|
|
for friend: Dictionary in _relationships.get_friends():
|
|
|
|
|
var fingerprint := str(friend.get("fingerprint", ""))
|
|
|
|
|
var live: Dictionary = _friend_presence.get(fingerprint, {})
|
|
|
|
|
result.append({
|
|
|
|
|
"fingerprint": fingerprint,
|
|
|
|
|
"display_name": str(
|
|
|
|
|
friend.get("last_known_display_name", "Player")
|
|
|
|
|
),
|
|
|
|
|
"online": bool(live.get("online", false)),
|
|
|
|
|
"room": (
|
|
|
|
|
live.get("room", {}).duplicate(true)
|
|
|
|
|
if typeof(live.get("room", {})) == TYPE_DICTIONARY
|
|
|
|
|
else {}
|
|
|
|
|
),
|
|
|
|
|
})
|
|
|
|
|
result.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
|
|
|
|
if bool(a["online"]) != bool(b["online"]):
|
|
|
|
|
return bool(a["online"])
|
|
|
|
|
return str(a["display_name"]).naturalnocasecmp_to(
|
|
|
|
|
str(b["display_name"])
|
|
|
|
|
) < 0
|
|
|
|
|
)
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func request_friend_presence() -> bool:
|
|
|
|
|
if not is_configured() or _relationships == null:
|
|
|
|
|
friend_presence_updated.emit(get_friend_presence())
|
|
|
|
|
return false
|
|
|
|
|
_refresh_social_state()
|
|
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func send_friend_invite(fingerprint: String) -> bool:
|
|
|
|
|
if (
|
|
|
|
|
_relationships == null
|
|
|
|
|
or not _relationships.is_friend(fingerprint)
|
|
|
|
|
or _relationships.is_blocked(fingerprint)
|
|
|
|
|
or not is_configured()
|
|
|
|
|
):
|
|
|
|
|
friend_invite_finished.emit(
|
|
|
|
|
false, "This person needs to be online to do this."
|
|
|
|
|
)
|
|
|
|
|
return false
|
|
|
|
|
if (
|
|
|
|
|
_session == null
|
|
|
|
|
or not _session.is_host()
|
|
|
|
|
or not _host_verified
|
|
|
|
|
or _lease_room_id.is_empty()
|
|
|
|
|
):
|
|
|
|
|
friend_invite_finished.emit(
|
|
|
|
|
false,
|
|
|
|
|
"List your open room in discovery before inviting friends.",
|
|
|
|
|
)
|
|
|
|
|
return false
|
|
|
|
|
if (
|
|
|
|
|
not _pending_invite_fingerprint.is_empty()
|
|
|
|
|
or _invitation_send_request.get_http_client_status()
|
|
|
|
|
!= HTTPClient.STATUS_DISCONNECTED
|
|
|
|
|
):
|
|
|
|
|
return false
|
|
|
|
|
var friend: Dictionary = _relationships.get_friend_record(fingerprint)
|
|
|
|
|
var inbox_token := str(friend.get("remote_invite_token", ""))
|
|
|
|
|
if inbox_token.is_empty():
|
|
|
|
|
friend_invite_finished.emit(false, "Friend invitation is unavailable.")
|
|
|
|
|
return false
|
|
|
|
|
var error := _post_social_json(
|
|
|
|
|
_invitation_send_request,
|
|
|
|
|
"/v1/invitations",
|
|
|
|
|
{
|
|
|
|
|
"inbox_token": inbox_token,
|
|
|
|
|
"room_id": _lease_room_id,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
if error != OK:
|
|
|
|
|
friend_invite_finished.emit(
|
|
|
|
|
false, "Friend invitation could not be sent."
|
|
|
|
|
)
|
|
|
|
|
return false
|
|
|
|
|
_pending_invite_fingerprint = fingerprint
|
|
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
2026-08-10 13:56:03 -04:00
|
|
|
func set_base_url_override(value: String) -> bool:
|
|
|
|
|
var normalized: String = value.strip_edges()
|
|
|
|
|
while normalized.ends_with("/"):
|
|
|
|
|
normalized = normalized.left(normalized.length() - 1)
|
|
|
|
|
if not normalized.is_empty() and not (
|
|
|
|
|
normalized.begins_with("https://")
|
|
|
|
|
or normalized.begins_with("http://")
|
|
|
|
|
):
|
|
|
|
|
return false
|
|
|
|
|
_base_url = normalized
|
|
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
2026-08-10 10:04:05 -04:00
|
|
|
func get_room_name() -> String:
|
|
|
|
|
return _room_name
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func is_discoverable() -> bool:
|
|
|
|
|
return _discoverable
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func get_host_status_message() -> String:
|
|
|
|
|
return _host_status_message
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func host_status_is_error() -> bool:
|
|
|
|
|
return _host_status_is_error
|
|
|
|
|
|
|
|
|
|
|
2026-08-11 10:37:03 -04:00
|
|
|
func get_host_state() -> HostState:
|
|
|
|
|
return _host_state
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func get_public_join_state() -> PublicJoinState:
|
|
|
|
|
return _public_join_state
|
|
|
|
|
|
|
|
|
|
|
2026-08-11 22:58:33 -04:00
|
|
|
func configure_dedicated_runtime(room_name: String) -> bool:
|
|
|
|
|
_host_settings_persistence_enabled = false
|
|
|
|
|
return set_room_name(room_name)
|
|
|
|
|
|
|
|
|
|
|
2026-08-10 10:04:05 -04:00
|
|
|
func set_room_name(value: String) -> bool:
|
|
|
|
|
var cleaned: String = _sanitize_room_name(value)
|
|
|
|
|
if cleaned.is_empty():
|
2026-08-11 10:37:03 -04:00
|
|
|
_set_host_state(HostState.ERROR)
|
2026-08-10 10:04:05 -04:00
|
|
|
_set_host_status("Room name cannot be empty.", true)
|
|
|
|
|
return false
|
|
|
|
|
if cleaned == _room_name:
|
2026-08-11 16:35:33 -04:00
|
|
|
if _room_name_uses_default:
|
|
|
|
|
_room_name_uses_default = false
|
|
|
|
|
_save_settings()
|
2026-08-10 10:04:05 -04:00
|
|
|
return true
|
|
|
|
|
_room_name = cleaned
|
2026-08-11 16:35:33 -04:00
|
|
|
_room_name_uses_default = false
|
2026-08-10 10:04:05 -04:00
|
|
|
_save_settings()
|
|
|
|
|
if _session != null:
|
|
|
|
|
_session.set_session_display_name(_room_name)
|
|
|
|
|
host_settings_changed.emit(_room_name, _discoverable)
|
|
|
|
|
if _discoverable:
|
|
|
|
|
_synchronize_host_lease()
|
|
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func set_discoverable(enabled: bool) -> bool:
|
|
|
|
|
if enabled and (
|
|
|
|
|
_session == null
|
|
|
|
|
or not _session.is_open_host()
|
|
|
|
|
or not is_configured()
|
|
|
|
|
):
|
2026-08-11 10:37:03 -04:00
|
|
|
_set_host_state(HostState.ERROR)
|
2026-08-10 10:04:05 -04:00
|
|
|
_set_host_status(
|
|
|
|
|
"Open the game before enabling discovery."
|
|
|
|
|
if is_configured()
|
|
|
|
|
else "Room discovery is not configured in this build.",
|
|
|
|
|
true,
|
|
|
|
|
)
|
|
|
|
|
return false
|
|
|
|
|
if _discoverable == enabled:
|
|
|
|
|
return true
|
|
|
|
|
_discoverable = enabled
|
|
|
|
|
host_settings_changed.emit(_room_name, _discoverable)
|
|
|
|
|
if enabled:
|
|
|
|
|
_heartbeat.start()
|
2026-08-11 10:37:03 -04:00
|
|
|
_set_host_state(
|
|
|
|
|
HostState.REGISTERING
|
|
|
|
|
if _session.is_dedicated_host()
|
|
|
|
|
else HostState.OPENING_PORT
|
|
|
|
|
)
|
2026-08-10 19:49:06 -04:00
|
|
|
_set_host_status("Opening public room…", false)
|
|
|
|
|
if _session.is_dedicated_host():
|
|
|
|
|
_synchronize_host_lease()
|
|
|
|
|
else:
|
|
|
|
|
_begin_upnp_mapping(_session.get_host_port())
|
2026-08-10 10:04:05 -04:00
|
|
|
else:
|
|
|
|
|
_heartbeat.stop()
|
2026-08-11 10:37:03 -04:00
|
|
|
_upnp_renew_timer.stop()
|
2026-08-10 19:49:06 -04:00
|
|
|
_traversal_timer.stop()
|
2026-08-10 10:04:05 -04:00
|
|
|
_remove_host_lease()
|
2026-08-10 19:49:06 -04:00
|
|
|
_begin_upnp_cleanup()
|
2026-08-11 10:37:03 -04:00
|
|
|
_set_host_state(HostState.PRIVATE)
|
2026-08-10 10:04:05 -04:00
|
|
|
_set_host_status("Room is open but unlisted.", false)
|
|
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
2026-08-10 19:49:06 -04:00
|
|
|
func is_public_join_preparing() -> bool:
|
|
|
|
|
return _join_request_in_flight
|
|
|
|
|
|
|
|
|
|
|
2026-08-13 22:20:43 -04:00
|
|
|
func preserve_public_join_for_session_switch() -> bool:
|
|
|
|
|
_preserve_pending_join_on_inactive = not _pending_join_token.is_empty()
|
|
|
|
|
return _preserve_pending_join_on_inactive
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func cancel_pending_public_join() -> void:
|
|
|
|
|
_preserve_pending_join_on_inactive = false
|
|
|
|
|
_set_public_join_state(PublicJoinState.IDLE)
|
|
|
|
|
_clear_pending_join()
|
|
|
|
|
|
|
|
|
|
|
2026-08-10 19:49:06 -04:00
|
|
|
func prepare_public_join(room: Dictionary) -> bool:
|
2026-08-11 10:37:03 -04:00
|
|
|
if _join_request_in_flight:
|
|
|
|
|
return false
|
|
|
|
|
if _session == null or not is_configured():
|
|
|
|
|
_set_public_join_state(PublicJoinState.ERROR)
|
2026-08-10 19:49:06 -04:00
|
|
|
return false
|
2026-08-11 16:35:33 -04:00
|
|
|
if is_own_room(room):
|
|
|
|
|
_set_public_join_state(PublicJoinState.ERROR)
|
|
|
|
|
public_join_status_changed.emit(
|
|
|
|
|
"You are already hosting this room.", true
|
|
|
|
|
)
|
|
|
|
|
return false
|
2026-08-10 19:49:06 -04:00
|
|
|
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():
|
2026-08-11 10:37:03 -04:00
|
|
|
_set_public_join_state(PublicJoinState.ERROR)
|
2026-08-10 19:49:06 -04:00
|
|
|
return false
|
|
|
|
|
_pending_join_endpoint = endpoint
|
|
|
|
|
_pending_join_room_id = room_id
|
|
|
|
|
_pending_join_token = ""
|
|
|
|
|
var url: String = "%s/v1/rooms/%s/join-attempts" % [
|
|
|
|
|
_base_url,
|
|
|
|
|
room_id.uri_encode(),
|
|
|
|
|
]
|
|
|
|
|
var error: Error = _join_request.request(
|
|
|
|
|
url,
|
|
|
|
|
PackedStringArray(),
|
|
|
|
|
HTTPClient.METHOD_POST,
|
|
|
|
|
"",
|
|
|
|
|
)
|
|
|
|
|
if error != OK:
|
2026-08-11 10:37:03 -04:00
|
|
|
_set_public_join_state(PublicJoinState.ERROR)
|
2026-08-10 19:49:06 -04:00
|
|
|
_clear_pending_join()
|
|
|
|
|
return false
|
|
|
|
|
_join_request_in_flight = true
|
2026-08-11 10:37:03 -04:00
|
|
|
_set_public_join_state(PublicJoinState.REQUESTING_ROUTE)
|
2026-08-10 19:49:06 -04:00
|
|
|
public_join_status_changed.emit("Opening a route to the room…", false)
|
|
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
2026-08-10 10:04:05 -04:00
|
|
|
func request_rooms() -> bool:
|
|
|
|
|
if not is_configured():
|
feat: expand progression and multiplayer systems
Add named save slots, progression import/export, and a unified play flow. Add live friend requests, presence, invitations, and relationship controls without durable discovery-server social storage. Advance the network protocol with isolated channels, movement reconciliation, late-join recovery, fishing replication, and animation synchronization. Preserve per-species catch totals, refine generated-world startup and water recovery, and complete the related input and interface improvements.
2026-08-23 20:48:38 -04:00
|
|
|
rooms_updated.emit(_empty_rooms())
|
2026-08-10 10:04:05 -04:00
|
|
|
browse_status_changed.emit(
|
|
|
|
|
"Public room discovery is not configured in this build.", true
|
|
|
|
|
)
|
|
|
|
|
return false
|
|
|
|
|
if _browse_request_in_flight:
|
|
|
|
|
return true
|
|
|
|
|
var game_version: String = str(
|
|
|
|
|
ProjectSettings.get_setting("application/config/version", "unknown")
|
|
|
|
|
)
|
|
|
|
|
var url: String = "%s/v1/rooms?game_version=%s&protocol_version=%d" % [
|
|
|
|
|
_base_url,
|
|
|
|
|
game_version.uri_encode(),
|
|
|
|
|
NetworkProtocol.PROTOCOL_VERSION,
|
|
|
|
|
]
|
|
|
|
|
var error: Error = _browse_request.request(url)
|
|
|
|
|
if error != OK:
|
feat: expand progression and multiplayer systems
Add named save slots, progression import/export, and a unified play flow. Add live friend requests, presence, invitations, and relationship controls without durable discovery-server social storage. Advance the network protocol with isolated channels, movement reconciliation, late-join recovery, fishing replication, and animation synchronization. Preserve per-species catch totals, refine generated-world startup and water recovery, and complete the related input and interface improvements.
2026-08-23 20:48:38 -04:00
|
|
|
rooms_updated.emit(_empty_rooms())
|
2026-08-10 10:04:05 -04:00
|
|
|
browse_status_changed.emit("Could not request public rooms.", true)
|
|
|
|
|
return false
|
|
|
|
|
_browse_request_in_flight = true
|
|
|
|
|
browse_status_changed.emit("Looking for public rooms…", false)
|
|
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func room_endpoint(room: Dictionary) -> String:
|
|
|
|
|
var address: String = str(room.get("address", "")).strip_edges()
|
|
|
|
|
var port: int = int(room.get("port", 0))
|
|
|
|
|
if address.is_empty() or port < 1 or port > 65535:
|
|
|
|
|
return ""
|
|
|
|
|
if ":" in address and not address.begins_with("["):
|
|
|
|
|
address = "[%s]" % address
|
|
|
|
|
return "%s:%d" % [address, port]
|
|
|
|
|
|
|
|
|
|
|
2026-08-11 16:35:33 -04:00
|
|
|
func is_own_room(room: Dictionary) -> bool:
|
|
|
|
|
return (
|
|
|
|
|
not _lease_room_id.is_empty()
|
|
|
|
|
and str(room.get("room_id", "")) == _lease_room_id
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-08-10 10:04:05 -04:00
|
|
|
func _configured_base_url() -> String:
|
|
|
|
|
var value: String = OS.get_environment(BASE_URL_ENVIRONMENT).strip_edges()
|
2026-08-25 22:18:20 -04:00
|
|
|
if value.is_empty():
|
|
|
|
|
value = OS.get_environment(
|
|
|
|
|
LEGACY_BASE_URL_ENVIRONMENT
|
|
|
|
|
).strip_edges()
|
2026-08-10 10:04:05 -04:00
|
|
|
if value.is_empty():
|
|
|
|
|
value = str(ProjectSettings.get_setting(BASE_URL_SETTING, "")).strip_edges()
|
|
|
|
|
while value.ends_with("/"):
|
|
|
|
|
value = value.left(value.length() - 1)
|
|
|
|
|
if not value.is_empty() and not (
|
|
|
|
|
value.begins_with("https://") or value.begins_with("http://")
|
|
|
|
|
):
|
2026-08-25 17:14:12 -04:00
|
|
|
push_warning("Ignored invalid straywild discovery URL.")
|
2026-08-10 10:04:05 -04:00
|
|
|
return ""
|
|
|
|
|
return value
|
|
|
|
|
|
|
|
|
|
|
2026-08-11 10:37:03 -04:00
|
|
|
func _on_heartbeat_timeout() -> void:
|
|
|
|
|
_synchronize_host_lease()
|
|
|
|
|
# NAT bindings can change during a long host session. Re-sending the signed
|
|
|
|
|
# probe lets discovery refresh the authoritative public endpoint in place.
|
|
|
|
|
_send_host_verification_probe()
|
|
|
|
|
|
|
|
|
|
|
2026-08-10 10:04:05 -04:00
|
|
|
func _synchronize_host_lease() -> void:
|
|
|
|
|
if not _should_advertise():
|
|
|
|
|
if not _lease_room_id.is_empty():
|
|
|
|
|
_remove_host_lease()
|
|
|
|
|
return
|
|
|
|
|
if _host_request_in_flight:
|
|
|
|
|
_host_sync_queued = true
|
|
|
|
|
return
|
|
|
|
|
var method: HTTPClient.Method = HTTPClient.METHOD_POST
|
|
|
|
|
var url: String = "%s/v1/rooms" % _base_url
|
|
|
|
|
var headers := PackedStringArray(["Content-Type: application/json"])
|
|
|
|
|
_host_request_kind = HostRequestKind.CREATE
|
|
|
|
|
if not _lease_room_id.is_empty():
|
|
|
|
|
method = HTTPClient.METHOD_PUT
|
|
|
|
|
url = "%s/v1/rooms/%s" % [_base_url, _lease_room_id.uri_encode()]
|
|
|
|
|
headers.append("Authorization: Bearer %s" % _lease_token)
|
|
|
|
|
_host_request_kind = HostRequestKind.UPDATE
|
2026-08-11 10:37:03 -04:00
|
|
|
elif _host_state != HostState.REGISTERING:
|
|
|
|
|
_set_host_state(HostState.REGISTERING)
|
2026-08-10 10:04:05 -04:00
|
|
|
_start_host_request(url, headers, method, JSON.stringify(_host_payload()))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _remove_host_lease() -> void:
|
|
|
|
|
if _lease_room_id.is_empty():
|
|
|
|
|
return
|
|
|
|
|
if _host_request_in_flight:
|
|
|
|
|
_host_sync_queued = true
|
|
|
|
|
return
|
|
|
|
|
var url: String = "%s/v1/rooms/%s" % [
|
|
|
|
|
_base_url, _lease_room_id.uri_encode()
|
|
|
|
|
]
|
|
|
|
|
var headers := PackedStringArray([
|
|
|
|
|
"Authorization: Bearer %s" % _lease_token,
|
|
|
|
|
])
|
|
|
|
|
_host_request_kind = HostRequestKind.DELETE
|
|
|
|
|
_start_host_request(url, headers, HTTPClient.METHOD_DELETE, "")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _start_host_request(
|
|
|
|
|
url: String,
|
|
|
|
|
headers: PackedStringArray,
|
|
|
|
|
method: HTTPClient.Method,
|
|
|
|
|
body: String,
|
|
|
|
|
) -> void:
|
|
|
|
|
var error: Error = _host_request.request(url, headers, method, body)
|
|
|
|
|
if error != OK:
|
|
|
|
|
_host_request_kind = HostRequestKind.NONE
|
2026-08-11 10:37:03 -04:00
|
|
|
_set_host_state(HostState.ERROR)
|
2026-08-10 10:04:05 -04:00
|
|
|
_set_host_status("Could not contact room discovery.", true)
|
|
|
|
|
return
|
|
|
|
|
_host_request_in_flight = true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _host_payload() -> Dictionary:
|
|
|
|
|
return {
|
|
|
|
|
"room_name": _room_name,
|
|
|
|
|
"port": _session.get_host_port(),
|
|
|
|
|
"current_players": _session.get_player_count(),
|
|
|
|
|
"max_players": _session.get_session_max_players(),
|
|
|
|
|
"game_version": str(
|
|
|
|
|
ProjectSettings.get_setting("application/config/version", "unknown")
|
|
|
|
|
),
|
|
|
|
|
"protocol_version": NetworkProtocol.PROTOCOL_VERSION,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _should_advertise() -> bool:
|
|
|
|
|
return (
|
|
|
|
|
_discoverable
|
|
|
|
|
and is_configured()
|
|
|
|
|
and _session != null
|
|
|
|
|
and _session.is_open_host()
|
2026-08-11 10:37:03 -04:00
|
|
|
and (
|
|
|
|
|
not _upnp_mapping_in_progress
|
|
|
|
|
or _upnp_operation_is_renewal
|
|
|
|
|
)
|
2026-08-10 10:04:05 -04:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _on_host_request_completed(
|
|
|
|
|
result: int,
|
|
|
|
|
response_code: int,
|
|
|
|
|
_headers: PackedStringArray,
|
|
|
|
|
body: PackedByteArray,
|
|
|
|
|
) -> void:
|
|
|
|
|
var completed_kind: HostRequestKind = _host_request_kind
|
|
|
|
|
_host_request_kind = HostRequestKind.NONE
|
|
|
|
|
_host_request_in_flight = false
|
|
|
|
|
var response: Dictionary = _parse_response_dictionary(body)
|
|
|
|
|
var transport_ok: bool = result == HTTPRequest.RESULT_SUCCESS
|
|
|
|
|
match completed_kind:
|
|
|
|
|
HostRequestKind.CREATE:
|
|
|
|
|
if transport_ok and response_code == HTTPClient.RESPONSE_CREATED:
|
|
|
|
|
var room: Dictionary = response.get("room", {})
|
|
|
|
|
_lease_room_id = str(room.get("room_id", ""))
|
|
|
|
|
_lease_token = str(response.get("lease_token", ""))
|
|
|
|
|
if _lease_room_id.is_empty() or _lease_token.is_empty():
|
|
|
|
|
_clear_lease()
|
2026-08-11 10:37:03 -04:00
|
|
|
_set_host_state(HostState.ERROR)
|
2026-08-10 10:04:05 -04:00
|
|
|
_set_host_status("Discovery returned an invalid lease.", true)
|
|
|
|
|
else:
|
2026-08-10 19:49:06 -04:00
|
|
|
_apply_traversal_response(response)
|
|
|
|
|
_host_verified = bool(room.get("verified", false))
|
|
|
|
|
_traversal_timer.start()
|
|
|
|
|
_send_host_verification_probe()
|
2026-08-11 10:37:03 -04:00
|
|
|
_set_host_state(
|
|
|
|
|
HostState.PUBLIC
|
|
|
|
|
if _host_verified
|
|
|
|
|
else HostState.VERIFYING
|
|
|
|
|
)
|
|
|
|
|
_set_host_status(
|
|
|
|
|
"Room is listed publicly."
|
|
|
|
|
if _host_verified
|
|
|
|
|
else "Checking the public route…",
|
|
|
|
|
false,
|
|
|
|
|
)
|
2026-08-10 10:04:05 -04:00
|
|
|
else:
|
2026-08-11 10:37:03 -04:00
|
|
|
_set_host_state(HostState.ERROR)
|
2026-08-10 10:04:05 -04:00
|
|
|
_set_host_status(_request_failure(response), true)
|
|
|
|
|
HostRequestKind.UPDATE:
|
|
|
|
|
if transport_ok and response_code == HTTPClient.RESPONSE_OK:
|
2026-08-10 19:49:06 -04:00
|
|
|
var room: Dictionary = response.get("room", {})
|
|
|
|
|
_host_verified = bool(room.get("verified", false))
|
2026-08-11 10:37:03 -04:00
|
|
|
_set_host_state(
|
|
|
|
|
HostState.PUBLIC
|
|
|
|
|
if _host_verified
|
|
|
|
|
else HostState.VERIFYING
|
|
|
|
|
)
|
2026-08-10 19:49:06 -04:00
|
|
|
_set_host_status(
|
|
|
|
|
"Room is listed publicly."
|
|
|
|
|
if _host_verified
|
|
|
|
|
else "Checking the public route…",
|
|
|
|
|
false,
|
|
|
|
|
)
|
2026-08-10 10:04:05 -04:00
|
|
|
elif response_code in [
|
|
|
|
|
HTTPClient.RESPONSE_UNAUTHORIZED,
|
|
|
|
|
HTTPClient.RESPONSE_NOT_FOUND,
|
|
|
|
|
]:
|
|
|
|
|
_clear_lease()
|
|
|
|
|
_host_sync_queued = true
|
|
|
|
|
else:
|
2026-08-11 10:37:03 -04:00
|
|
|
_set_host_state(HostState.ERROR)
|
2026-08-10 10:04:05 -04:00
|
|
|
_set_host_status(_request_failure(response), true)
|
|
|
|
|
HostRequestKind.DELETE:
|
|
|
|
|
_clear_lease()
|
2026-08-11 10:37:03 -04:00
|
|
|
if not transport_ok or response_code not in [
|
2026-08-10 10:04:05 -04:00
|
|
|
HTTPClient.RESPONSE_NO_CONTENT,
|
|
|
|
|
HTTPClient.RESPONSE_NOT_FOUND,
|
2026-08-11 10:37:03 -04:00
|
|
|
]:
|
|
|
|
|
_set_host_state(HostState.ERROR)
|
2026-08-10 10:04:05 -04:00
|
|
|
_set_host_status(_request_failure(response), true)
|
|
|
|
|
_:
|
|
|
|
|
pass
|
|
|
|
|
if not _should_advertise() and not _lease_room_id.is_empty():
|
|
|
|
|
_host_sync_queued = true
|
|
|
|
|
if _host_sync_queued:
|
|
|
|
|
_host_sync_queued = false
|
|
|
|
|
call_deferred("_synchronize_host_lease")
|
|
|
|
|
|
|
|
|
|
|
2026-08-10 19:49:06 -04:00
|
|
|
func _on_join_request_completed(
|
|
|
|
|
result: int,
|
|
|
|
|
response_code: int,
|
|
|
|
|
_headers: PackedStringArray,
|
|
|
|
|
body: PackedByteArray,
|
|
|
|
|
) -> void:
|
|
|
|
|
_join_request_in_flight = false
|
|
|
|
|
var response: Dictionary = _parse_response_dictionary(body)
|
|
|
|
|
if (
|
|
|
|
|
result != HTTPRequest.RESULT_SUCCESS
|
|
|
|
|
or response_code != HTTPClient.RESPONSE_CREATED
|
|
|
|
|
):
|
2026-08-11 10:37:03 -04:00
|
|
|
_set_public_join_state(PublicJoinState.ERROR)
|
2026-08-10 19:49:06 -04:00
|
|
|
public_join_status_changed.emit(_request_failure(response), true)
|
|
|
|
|
_clear_pending_join()
|
|
|
|
|
return
|
|
|
|
|
_pending_join_token = str(response.get("join_token", ""))
|
|
|
|
|
_apply_traversal_response(response)
|
|
|
|
|
if (
|
|
|
|
|
_pending_join_token.is_empty()
|
|
|
|
|
or _traversal_host.is_empty()
|
|
|
|
|
or _traversal_port < 1
|
|
|
|
|
):
|
2026-08-11 10:37:03 -04:00
|
|
|
_set_public_join_state(PublicJoinState.ERROR)
|
2026-08-10 19:49:06 -04:00
|
|
|
public_join_status_changed.emit(
|
|
|
|
|
"Discovery returned an invalid traversal route.", true
|
|
|
|
|
)
|
|
|
|
|
_clear_pending_join()
|
|
|
|
|
return
|
2026-08-11 10:37:03 -04:00
|
|
|
_set_public_join_state(PublicJoinState.CONNECTING)
|
2026-08-10 19:49:06 -04:00
|
|
|
public_join_status_changed.emit("Connecting…", false)
|
|
|
|
|
public_join_prepared.emit(_pending_join_endpoint)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _on_traversal_timer_timeout() -> void:
|
|
|
|
|
if not _should_advertise() or _lease_room_id.is_empty():
|
|
|
|
|
_traversal_timer.stop()
|
|
|
|
|
return
|
|
|
|
|
if not _host_verified:
|
|
|
|
|
_send_host_verification_probe()
|
|
|
|
|
_synchronize_host_lease()
|
|
|
|
|
return
|
|
|
|
|
if _traversal_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
|
|
|
|
|
return
|
|
|
|
|
var url: String = "%s/v1/rooms/%s/join-attempts" % [
|
|
|
|
|
_base_url,
|
|
|
|
|
_lease_room_id.uri_encode(),
|
|
|
|
|
]
|
|
|
|
|
var headers := PackedStringArray([
|
|
|
|
|
"Authorization: Bearer %s" % _lease_token,
|
|
|
|
|
])
|
|
|
|
|
var error: Error = _traversal_request.request(url, headers)
|
|
|
|
|
if error != OK:
|
2026-08-11 10:37:03 -04:00
|
|
|
_set_host_state(HostState.ERROR)
|
2026-08-10 19:49:06 -04:00
|
|
|
_set_host_status("Could not check incoming public connections.", true)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _on_traversal_request_completed(
|
|
|
|
|
result: int,
|
|
|
|
|
response_code: int,
|
|
|
|
|
_headers: PackedStringArray,
|
|
|
|
|
body: PackedByteArray,
|
|
|
|
|
) -> void:
|
|
|
|
|
if result != HTTPRequest.RESULT_SUCCESS or response_code != HTTPClient.RESPONSE_OK:
|
|
|
|
|
return
|
|
|
|
|
var response: Dictionary = _parse_response_dictionary(body)
|
|
|
|
|
var endpoints: Variant = response.get("endpoints", [])
|
|
|
|
|
if typeof(endpoints) != TYPE_ARRAY:
|
|
|
|
|
return
|
|
|
|
|
for value: Variant in endpoints:
|
|
|
|
|
if typeof(value) != TYPE_DICTIONARY:
|
|
|
|
|
continue
|
|
|
|
|
var endpoint: Dictionary = value
|
|
|
|
|
var address: String = str(endpoint.get("address", ""))
|
|
|
|
|
var port: int = int(endpoint.get("port", 0))
|
|
|
|
|
if address.is_empty() or port < 1 or port > 65535:
|
|
|
|
|
continue
|
2026-08-25 17:14:12 -04:00
|
|
|
var punch: PackedByteArray = "straywild_PUNCH_V1".to_utf8_buffer()
|
2026-08-10 19:49:06 -04:00
|
|
|
for _attempt: int in 3:
|
|
|
|
|
_session.send_traversal_packet(address, port, punch)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _apply_traversal_response(response: Dictionary) -> void:
|
|
|
|
|
var traversal: Variant = response.get("traversal", {})
|
|
|
|
|
if typeof(traversal) != TYPE_DICTIONARY:
|
|
|
|
|
return
|
|
|
|
|
var details: Dictionary = traversal
|
|
|
|
|
_traversal_host = str(details.get("host", "")).strip_edges()
|
|
|
|
|
_traversal_port = int(details.get("port", 0))
|
|
|
|
|
_host_verification_token = str(
|
|
|
|
|
details.get("verification_token", "")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _send_host_verification_probe() -> void:
|
|
|
|
|
if (
|
|
|
|
|
_session == null
|
|
|
|
|
or _lease_room_id.is_empty()
|
|
|
|
|
or _host_verification_token.is_empty()
|
|
|
|
|
):
|
|
|
|
|
return
|
|
|
|
|
_send_traversal_probe({
|
|
|
|
|
"kind": "host",
|
|
|
|
|
"room_id": _lease_room_id,
|
|
|
|
|
"token": _host_verification_token,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _send_pending_join_probe() -> void:
|
|
|
|
|
if (
|
|
|
|
|
_session == null
|
|
|
|
|
or _pending_join_token.is_empty()
|
|
|
|
|
or _session.state not in [
|
|
|
|
|
NetworkSession.State.CONNECTING,
|
|
|
|
|
NetworkSession.State.AUTHENTICATING,
|
|
|
|
|
]
|
|
|
|
|
):
|
|
|
|
|
_join_probe_timer.stop()
|
|
|
|
|
return
|
|
|
|
|
_send_traversal_probe({
|
|
|
|
|
"kind": "join",
|
|
|
|
|
"room_id": _pending_join_room_id,
|
|
|
|
|
"token": _pending_join_token,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _send_traversal_probe(payload: Dictionary) -> void:
|
|
|
|
|
if _traversal_host.is_empty() or _traversal_port < 1:
|
|
|
|
|
return
|
|
|
|
|
var packet: PackedByteArray = (
|
|
|
|
|
TRAVERSAL_PACKET_PREFIX + JSON.stringify(payload)
|
|
|
|
|
).to_utf8_buffer()
|
|
|
|
|
_session.send_traversal_packet(_traversal_host, _traversal_port, packet)
|
|
|
|
|
|
|
|
|
|
|
2026-08-11 10:37:03 -04:00
|
|
|
func _begin_upnp_mapping(port: int, is_renewal: bool = false) -> void:
|
2026-08-10 19:49:06 -04:00
|
|
|
if _upnp_mapping_in_progress or port < 1:
|
2026-08-11 10:37:03 -04:00
|
|
|
if not is_renewal:
|
|
|
|
|
_synchronize_host_lease()
|
2026-08-10 19:49:06 -04:00
|
|
|
return
|
|
|
|
|
_upnp_mapping_in_progress = true
|
2026-08-11 10:37:03 -04:00
|
|
|
_upnp_operation_is_renewal = is_renewal
|
2026-08-10 19:49:06 -04:00
|
|
|
_upnp_thread = Thread.new()
|
|
|
|
|
var error: Error = _upnp_thread.start(
|
|
|
|
|
_configure_upnp_mapping.bind(port)
|
|
|
|
|
)
|
|
|
|
|
if error != OK:
|
|
|
|
|
_upnp_thread = null
|
|
|
|
|
_upnp_mapping_in_progress = false
|
2026-08-11 10:37:03 -04:00
|
|
|
_upnp_operation_is_renewal = false
|
|
|
|
|
if is_renewal:
|
|
|
|
|
_schedule_upnp_renewal(UPNP_RETRY_INTERVAL_SECONDS)
|
2026-08-10 19:49:06 -04:00
|
|
|
_synchronize_host_lease()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _configure_upnp_mapping(port: int) -> Dictionary:
|
|
|
|
|
var upnp := UPNP.new()
|
|
|
|
|
var discovery_result: int = upnp.discover()
|
|
|
|
|
if discovery_result != UPNP.UPNP_RESULT_SUCCESS:
|
|
|
|
|
return {"success": false, "upnp": upnp, "port": port}
|
|
|
|
|
var mapping_result: int = upnp.add_port_mapping(
|
|
|
|
|
port,
|
|
|
|
|
port,
|
2026-08-25 17:14:12 -04:00
|
|
|
"straywild",
|
2026-08-10 19:49:06 -04:00
|
|
|
"UDP",
|
|
|
|
|
UPNP_MAPPING_DURATION_SECONDS,
|
|
|
|
|
)
|
|
|
|
|
if mapping_result == UPNP.UPNP_RESULT_ONLY_PERMANENT_LEASE_SUPPORTED:
|
|
|
|
|
mapping_result = upnp.add_port_mapping(
|
2026-08-25 17:14:12 -04:00
|
|
|
port, port, "straywild", "UDP", 0
|
2026-08-10 19:49:06 -04:00
|
|
|
)
|
|
|
|
|
return {
|
|
|
|
|
"success": mapping_result == UPNP.UPNP_RESULT_SUCCESS,
|
|
|
|
|
"upnp": upnp,
|
|
|
|
|
"port": port,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _process(_delta: float) -> void:
|
|
|
|
|
if _upnp_thread == null or _upnp_thread.is_alive():
|
|
|
|
|
return
|
2026-08-11 10:37:03 -04:00
|
|
|
var was_renewal: bool = _upnp_operation_is_renewal
|
2026-08-10 19:49:06 -04:00
|
|
|
var result: Variant = _upnp_thread.wait_to_finish()
|
|
|
|
|
_upnp_thread = null
|
|
|
|
|
_upnp_mapping_in_progress = false
|
2026-08-11 10:37:03 -04:00
|
|
|
_upnp_operation_is_renewal = false
|
|
|
|
|
var mapping_succeeded: bool = false
|
2026-08-10 19:49:06 -04:00
|
|
|
if typeof(result) == TYPE_DICTIONARY:
|
|
|
|
|
var details: Dictionary = result
|
|
|
|
|
if bool(details.get("success", false)):
|
|
|
|
|
_upnp = details.get("upnp") as UPNP
|
|
|
|
|
_upnp_mapped_port = int(details.get("port", 0))
|
2026-08-11 10:37:03 -04:00
|
|
|
mapping_succeeded = true
|
2026-08-10 19:49:06 -04:00
|
|
|
if not _discoverable:
|
|
|
|
|
_begin_upnp_cleanup()
|
|
|
|
|
return
|
2026-08-11 10:37:03 -04:00
|
|
|
if mapping_succeeded:
|
|
|
|
|
_schedule_upnp_renewal(UPNP_RENEW_INTERVAL_SECONDS)
|
|
|
|
|
elif was_renewal:
|
|
|
|
|
_schedule_upnp_renewal(UPNP_RETRY_INTERVAL_SECONDS)
|
2026-08-10 19:49:06 -04:00
|
|
|
_synchronize_host_lease()
|
|
|
|
|
|
|
|
|
|
|
2026-08-11 10:37:03 -04:00
|
|
|
func _schedule_upnp_renewal(delay_seconds: float) -> void:
|
|
|
|
|
if (
|
|
|
|
|
_upnp_mapped_port < 1
|
|
|
|
|
or not _discoverable
|
|
|
|
|
or _session == null
|
|
|
|
|
or _session.is_dedicated_host()
|
|
|
|
|
):
|
|
|
|
|
_upnp_renew_timer.stop()
|
|
|
|
|
return
|
|
|
|
|
_upnp_renew_timer.start(delay_seconds)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _on_upnp_renew_timer_timeout() -> void:
|
|
|
|
|
if (
|
|
|
|
|
not _discoverable
|
|
|
|
|
or _session == null
|
|
|
|
|
or not _session.is_open_host()
|
|
|
|
|
or _session.is_dedicated_host()
|
|
|
|
|
or _upnp_mapped_port < 1
|
|
|
|
|
):
|
|
|
|
|
return
|
|
|
|
|
_begin_upnp_mapping(_upnp_mapped_port, true)
|
|
|
|
|
|
|
|
|
|
|
2026-08-10 19:49:06 -04:00
|
|
|
func _begin_upnp_cleanup() -> void:
|
2026-08-11 10:37:03 -04:00
|
|
|
_upnp_renew_timer.stop()
|
2026-08-10 19:49:06 -04:00
|
|
|
if _upnp == null or _upnp_mapped_port < 1:
|
|
|
|
|
return
|
|
|
|
|
_upnp.delete_port_mapping(_upnp_mapped_port, "UDP")
|
|
|
|
|
_upnp = null
|
|
|
|
|
_upnp_mapped_port = 0
|
|
|
|
|
|
|
|
|
|
|
2026-08-10 10:04:05 -04:00
|
|
|
func _on_browse_request_completed(
|
|
|
|
|
result: int,
|
|
|
|
|
response_code: int,
|
|
|
|
|
_headers: PackedStringArray,
|
|
|
|
|
body: PackedByteArray,
|
|
|
|
|
) -> void:
|
|
|
|
|
_browse_request_in_flight = false
|
|
|
|
|
var response: Dictionary = _parse_response_dictionary(body)
|
|
|
|
|
if result != HTTPRequest.RESULT_SUCCESS or response_code != HTTPClient.RESPONSE_OK:
|
feat: expand progression and multiplayer systems
Add named save slots, progression import/export, and a unified play flow. Add live friend requests, presence, invitations, and relationship controls without durable discovery-server social storage. Advance the network protocol with isolated channels, movement reconciliation, late-join recovery, fishing replication, and animation synchronization. Preserve per-species catch totals, refine generated-world startup and water recovery, and complete the related input and interface improvements.
2026-08-23 20:48:38 -04:00
|
|
|
rooms_updated.emit(_empty_rooms())
|
2026-08-10 10:04:05 -04:00
|
|
|
browse_status_changed.emit(_request_failure(response), true)
|
|
|
|
|
return
|
|
|
|
|
var raw_rooms: Variant = response.get("rooms", [])
|
|
|
|
|
if typeof(raw_rooms) != TYPE_ARRAY:
|
feat: expand progression and multiplayer systems
Add named save slots, progression import/export, and a unified play flow. Add live friend requests, presence, invitations, and relationship controls without durable discovery-server social storage. Advance the network protocol with isolated channels, movement reconciliation, late-join recovery, fishing replication, and animation synchronization. Preserve per-species catch totals, refine generated-world startup and water recovery, and complete the related input and interface improvements.
2026-08-23 20:48:38 -04:00
|
|
|
rooms_updated.emit(_empty_rooms())
|
2026-08-10 10:04:05 -04:00
|
|
|
browse_status_changed.emit("Discovery returned an invalid room list.", true)
|
|
|
|
|
return
|
|
|
|
|
var rooms: Array[Dictionary] = []
|
|
|
|
|
for value: Variant in raw_rooms:
|
|
|
|
|
if typeof(value) != TYPE_DICTIONARY:
|
|
|
|
|
continue
|
|
|
|
|
var room: Dictionary = value
|
|
|
|
|
if _valid_public_room(room):
|
|
|
|
|
rooms.append(room.duplicate(true))
|
|
|
|
|
rooms_updated.emit(rooms)
|
|
|
|
|
browse_status_changed.emit(
|
|
|
|
|
"No public rooms are available."
|
|
|
|
|
if rooms.is_empty()
|
|
|
|
|
else "%d public room%s found." % [rooms.size(), "" if rooms.size() == 1 else "s"],
|
|
|
|
|
false,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _valid_public_room(room: Dictionary) -> bool:
|
|
|
|
|
var expected_version: String = str(
|
|
|
|
|
ProjectSettings.get_setting("application/config/version", "unknown")
|
|
|
|
|
)
|
|
|
|
|
return (
|
|
|
|
|
typeof(room.get("room_id")) == TYPE_STRING
|
|
|
|
|
and typeof(room.get("room_name")) == TYPE_STRING
|
|
|
|
|
and typeof(room.get("address")) == TYPE_STRING
|
|
|
|
|
and typeof(room.get("game_version")) == TYPE_STRING
|
|
|
|
|
and str(room.get("game_version")) == expected_version
|
|
|
|
|
and _valid_json_integer(room.get("port"), 1, 65535)
|
2026-08-10 13:56:03 -04:00
|
|
|
and _valid_json_integer(room.get("current_players"), 0, 128)
|
2026-08-10 10:04:05 -04:00
|
|
|
and _valid_json_integer(room.get("max_players"), 1, 128)
|
|
|
|
|
and int(room["current_players"]) <= int(room["max_players"])
|
|
|
|
|
and _valid_json_integer(
|
|
|
|
|
room.get("protocol_version"),
|
|
|
|
|
NetworkProtocol.PROTOCOL_VERSION,
|
|
|
|
|
NetworkProtocol.PROTOCOL_VERSION,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _valid_json_integer(value: Variant, minimum: int, maximum: int) -> bool:
|
|
|
|
|
if typeof(value) == TYPE_INT:
|
|
|
|
|
return int(value) >= minimum and int(value) <= maximum
|
|
|
|
|
if typeof(value) != TYPE_FLOAT:
|
|
|
|
|
return false
|
|
|
|
|
var number: float = float(value)
|
|
|
|
|
return (
|
|
|
|
|
is_finite(number)
|
|
|
|
|
and number == floor(number)
|
|
|
|
|
and number >= float(minimum)
|
|
|
|
|
and number <= float(maximum)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
feat: expand progression and multiplayer systems
Add named save slots, progression import/export, and a unified play flow. Add live friend requests, presence, invitations, and relationship controls without durable discovery-server social storage. Advance the network protocol with isolated channels, movement reconciliation, late-join recovery, fishing replication, and animation synchronization. Preserve per-species catch totals, refine generated-world startup and water recovery, and complete the related input and interface improvements.
2026-08-23 20:48:38 -04:00
|
|
|
func _make_social_request(name: String, callback: Callable) -> HTTPRequest:
|
|
|
|
|
var request := HTTPRequest.new()
|
|
|
|
|
request.name = name
|
|
|
|
|
request.timeout = REQUEST_TIMEOUT_SECONDS
|
|
|
|
|
add_child(request)
|
|
|
|
|
request.request_completed.connect(callback)
|
|
|
|
|
return request
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _social_payload(fields: Dictionary = {}) -> Dictionary:
|
|
|
|
|
var payload: Dictionary = fields.duplicate(true)
|
|
|
|
|
payload["game_version"] = NetworkProtocol.game_version()
|
|
|
|
|
payload["protocol_version"] = NetworkProtocol.PROTOCOL_VERSION
|
|
|
|
|
return payload
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _post_social_json(
|
|
|
|
|
request: HTTPRequest,
|
|
|
|
|
path: String,
|
|
|
|
|
fields: Dictionary,
|
|
|
|
|
) -> Error:
|
|
|
|
|
return request.request(
|
|
|
|
|
_base_url + path,
|
|
|
|
|
PackedStringArray(["Content-Type: application/json"]),
|
|
|
|
|
HTTPClient.METHOD_POST,
|
|
|
|
|
JSON.stringify(_social_payload(fields)),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _refresh_social_state() -> void:
|
|
|
|
|
if not is_configured() or _relationships == null:
|
|
|
|
|
return
|
|
|
|
|
_publish_friend_presence()
|
|
|
|
|
_query_friend_presence()
|
|
|
|
|
_poll_friend_invitations()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _publish_friend_presence() -> void:
|
|
|
|
|
if (
|
|
|
|
|
_presence_request.get_http_client_status()
|
|
|
|
|
!= HTTPClient.STATUS_DISCONNECTED
|
|
|
|
|
):
|
|
|
|
|
return
|
|
|
|
|
var current_tokens := _friend_social_values(
|
|
|
|
|
"local_presence_write_token"
|
|
|
|
|
)
|
|
|
|
|
var online := _presence_sharing and not current_tokens.is_empty()
|
|
|
|
|
var publish_online := online
|
|
|
|
|
var tokens := PackedStringArray()
|
|
|
|
|
if online:
|
|
|
|
|
# Revoke removed or blocked friendships before refreshing the remaining
|
|
|
|
|
# capabilities. This makes status disappear on the next request instead of
|
|
|
|
|
# waiting for the server's short presence TTL.
|
|
|
|
|
tokens = _presence_tokens_not_in(
|
|
|
|
|
_published_presence_tokens, current_tokens
|
|
|
|
|
)
|
|
|
|
|
if not tokens.is_empty():
|
|
|
|
|
publish_online = false
|
|
|
|
|
else:
|
|
|
|
|
tokens = current_tokens
|
|
|
|
|
else:
|
|
|
|
|
tokens = _published_presence_tokens
|
|
|
|
|
if tokens.is_empty():
|
|
|
|
|
return
|
|
|
|
|
var error := _post_social_json(
|
|
|
|
|
_presence_request,
|
|
|
|
|
"/v1/presence",
|
|
|
|
|
{
|
|
|
|
|
"display_name": _session.get_local_display_name(),
|
|
|
|
|
"room_id": _current_presence_room_id(),
|
|
|
|
|
"online": publish_online,
|
|
|
|
|
"write_tokens": Array(tokens),
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
if error == OK:
|
|
|
|
|
_pending_presence_online = publish_online
|
|
|
|
|
_pending_presence_tokens = tokens.duplicate()
|
|
|
|
|
else:
|
|
|
|
|
social_status_changed.emit("Could not update friend presence.", true)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _query_friend_presence() -> void:
|
|
|
|
|
if (
|
|
|
|
|
_presence_query_request.get_http_client_status()
|
|
|
|
|
!= HTTPClient.STATUS_DISCONNECTED
|
|
|
|
|
):
|
|
|
|
|
return
|
|
|
|
|
var channels := _friend_social_values("remote_presence_channel")
|
|
|
|
|
if channels.is_empty():
|
|
|
|
|
var had_presence := not _friend_presence.is_empty()
|
|
|
|
|
_friend_presence.clear()
|
|
|
|
|
if had_presence:
|
|
|
|
|
friend_presence_updated.emit(get_friend_presence())
|
|
|
|
|
return
|
|
|
|
|
var error := _post_social_json(
|
|
|
|
|
_presence_query_request,
|
|
|
|
|
"/v1/presence/query",
|
|
|
|
|
{"channels": Array(channels)},
|
|
|
|
|
)
|
|
|
|
|
if error != OK:
|
|
|
|
|
social_status_changed.emit("Could not refresh friend status.", true)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _poll_friend_invitations() -> void:
|
|
|
|
|
if (
|
|
|
|
|
_invitation_poll_request.get_http_client_status()
|
|
|
|
|
!= HTTPClient.STATUS_DISCONNECTED
|
|
|
|
|
):
|
|
|
|
|
return
|
|
|
|
|
var tokens := _friend_social_values("local_invite_token")
|
|
|
|
|
if tokens.is_empty():
|
|
|
|
|
return
|
|
|
|
|
var error := _post_social_json(
|
|
|
|
|
_invitation_poll_request,
|
|
|
|
|
"/v1/invitations/poll",
|
|
|
|
|
{"inbox_tokens": Array(tokens)},
|
|
|
|
|
)
|
|
|
|
|
if error != OK:
|
|
|
|
|
social_status_changed.emit("Could not check friend invitations.", true)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _friend_social_values(key: String) -> PackedStringArray:
|
|
|
|
|
var result := PackedStringArray()
|
|
|
|
|
if _relationships == null:
|
|
|
|
|
return result
|
|
|
|
|
for friend: Dictionary in _relationships.get_friends():
|
|
|
|
|
var value := str(friend.get(key, ""))
|
|
|
|
|
if not value.is_empty() and value not in result:
|
|
|
|
|
result.append(value)
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _current_presence_room_id() -> String:
|
|
|
|
|
if _session == null:
|
|
|
|
|
return ""
|
|
|
|
|
if _session.is_host() and _host_verified:
|
|
|
|
|
return _lease_room_id
|
|
|
|
|
if _session.is_joined_client():
|
|
|
|
|
return _joined_public_room_id
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _on_presence_request_completed(
|
|
|
|
|
result: int,
|
|
|
|
|
response_code: int,
|
|
|
|
|
_headers: PackedStringArray,
|
|
|
|
|
body: PackedByteArray,
|
|
|
|
|
) -> void:
|
|
|
|
|
var published_online := _pending_presence_online
|
|
|
|
|
var request_tokens := _pending_presence_tokens.duplicate()
|
|
|
|
|
_pending_presence_online = false
|
|
|
|
|
_pending_presence_tokens = PackedStringArray()
|
|
|
|
|
var response := _parse_response_dictionary(body)
|
|
|
|
|
if result != HTTPRequest.RESULT_SUCCESS or response_code != HTTPClient.RESPONSE_OK:
|
|
|
|
|
social_status_changed.emit(_request_failure(response), true)
|
|
|
|
|
return
|
|
|
|
|
if published_online:
|
|
|
|
|
for token: String in request_tokens:
|
|
|
|
|
if token not in _published_presence_tokens:
|
|
|
|
|
_published_presence_tokens.append(token)
|
|
|
|
|
else:
|
|
|
|
|
for token: String in request_tokens:
|
|
|
|
|
var index := _published_presence_tokens.find(token)
|
|
|
|
|
if index >= 0:
|
|
|
|
|
_published_presence_tokens.remove_at(index)
|
|
|
|
|
if _presence_publish_needs_follow_up():
|
|
|
|
|
call_deferred("_publish_friend_presence")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _presence_publish_needs_follow_up() -> bool:
|
|
|
|
|
var desired := (
|
|
|
|
|
_friend_social_values("local_presence_write_token")
|
|
|
|
|
if _presence_sharing
|
|
|
|
|
else PackedStringArray()
|
|
|
|
|
)
|
|
|
|
|
return (
|
|
|
|
|
not _presence_tokens_not_in(
|
|
|
|
|
_published_presence_tokens, desired
|
|
|
|
|
).is_empty()
|
|
|
|
|
or not _presence_tokens_not_in(
|
|
|
|
|
desired, _published_presence_tokens
|
|
|
|
|
).is_empty()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _presence_tokens_not_in(
|
|
|
|
|
first: PackedStringArray,
|
|
|
|
|
second: PackedStringArray,
|
|
|
|
|
) -> PackedStringArray:
|
|
|
|
|
var result := PackedStringArray()
|
|
|
|
|
for token: String in first:
|
|
|
|
|
if token not in second:
|
|
|
|
|
result.append(token)
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _on_presence_query_completed(
|
|
|
|
|
result: int,
|
|
|
|
|
response_code: int,
|
|
|
|
|
_headers: PackedStringArray,
|
|
|
|
|
body: PackedByteArray,
|
|
|
|
|
) -> void:
|
|
|
|
|
var response := _parse_response_dictionary(body)
|
|
|
|
|
if result != HTTPRequest.RESULT_SUCCESS or response_code != HTTPClient.RESPONSE_OK:
|
|
|
|
|
social_status_changed.emit(_request_failure(response), true)
|
|
|
|
|
return
|
|
|
|
|
var by_channel: Dictionary[String, String] = {}
|
|
|
|
|
for friend: Dictionary in _relationships.get_friends():
|
|
|
|
|
by_channel[str(friend.get("remote_presence_channel", ""))] = str(
|
|
|
|
|
friend.get("fingerprint", "")
|
|
|
|
|
)
|
|
|
|
|
var next_presence: Dictionary[String, Dictionary] = {}
|
|
|
|
|
var raw_presence: Variant = response.get("presence", [])
|
|
|
|
|
if typeof(raw_presence) == TYPE_ARRAY:
|
|
|
|
|
for value: Variant in raw_presence:
|
|
|
|
|
if typeof(value) != TYPE_DICTIONARY:
|
|
|
|
|
continue
|
|
|
|
|
var presence: Dictionary = value
|
|
|
|
|
var fingerprint := str(
|
|
|
|
|
by_channel.get(str(presence.get("channel", "")), "")
|
|
|
|
|
)
|
|
|
|
|
if fingerprint.is_empty() or _relationships.is_blocked(fingerprint):
|
|
|
|
|
continue
|
|
|
|
|
var room: Dictionary = {}
|
|
|
|
|
var raw_room: Variant = presence.get("room", {})
|
|
|
|
|
if typeof(raw_room) == TYPE_DICTIONARY and _valid_public_room(raw_room):
|
|
|
|
|
room = (raw_room as Dictionary).duplicate(true)
|
|
|
|
|
next_presence[fingerprint] = {
|
|
|
|
|
"online": true,
|
|
|
|
|
"room": room,
|
|
|
|
|
}
|
|
|
|
|
if next_presence == _friend_presence:
|
|
|
|
|
return
|
|
|
|
|
_friend_presence = next_presence
|
|
|
|
|
friend_presence_updated.emit(get_friend_presence())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _on_invitation_poll_completed(
|
|
|
|
|
result: int,
|
|
|
|
|
response_code: int,
|
|
|
|
|
_headers: PackedStringArray,
|
|
|
|
|
body: PackedByteArray,
|
|
|
|
|
) -> void:
|
|
|
|
|
var response := _parse_response_dictionary(body)
|
|
|
|
|
if result != HTTPRequest.RESULT_SUCCESS or response_code != HTTPClient.RESPONSE_OK:
|
|
|
|
|
return
|
|
|
|
|
var by_inbox: Dictionary[String, Dictionary] = {}
|
|
|
|
|
for friend: Dictionary in _relationships.get_friends():
|
|
|
|
|
var token := str(friend.get("local_invite_token", ""))
|
|
|
|
|
if not token.is_empty():
|
|
|
|
|
by_inbox[PlayerRelationshipStore.invite_inbox_id(token)] = friend
|
|
|
|
|
var invitations: Variant = response.get("invitations", [])
|
|
|
|
|
if typeof(invitations) != TYPE_ARRAY:
|
|
|
|
|
return
|
|
|
|
|
for value: Variant in invitations:
|
|
|
|
|
if typeof(value) != TYPE_DICTIONARY:
|
|
|
|
|
continue
|
|
|
|
|
var invitation: Dictionary = value
|
|
|
|
|
var friend: Dictionary = by_inbox.get(
|
|
|
|
|
str(invitation.get("inbox_id", "")), {}
|
|
|
|
|
)
|
|
|
|
|
var raw_room: Variant = invitation.get("room", {})
|
|
|
|
|
if friend.is_empty() or typeof(raw_room) != TYPE_DICTIONARY:
|
|
|
|
|
continue
|
|
|
|
|
var room := raw_room as Dictionary
|
|
|
|
|
if not _valid_public_room(room):
|
|
|
|
|
continue
|
|
|
|
|
var fingerprint := str(friend.get("fingerprint", ""))
|
|
|
|
|
if _relationships.is_blocked(fingerprint):
|
|
|
|
|
continue
|
|
|
|
|
friend_invite_received.emit(
|
|
|
|
|
fingerprint,
|
|
|
|
|
str(friend.get("last_known_display_name", "Player")),
|
|
|
|
|
room.duplicate(true),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _on_invitation_send_completed(
|
|
|
|
|
result: int,
|
|
|
|
|
response_code: int,
|
|
|
|
|
_headers: PackedStringArray,
|
|
|
|
|
body: PackedByteArray,
|
|
|
|
|
) -> void:
|
|
|
|
|
_pending_invite_fingerprint = ""
|
|
|
|
|
var response := _parse_response_dictionary(body)
|
|
|
|
|
var success := (
|
|
|
|
|
result == HTTPRequest.RESULT_SUCCESS
|
|
|
|
|
and response_code == HTTPClient.RESPONSE_CREATED
|
|
|
|
|
)
|
|
|
|
|
friend_invite_finished.emit(
|
|
|
|
|
success,
|
|
|
|
|
"Invitation sent." if success else _request_failure(response),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _on_social_relationship_changed(_fingerprint: String) -> void:
|
|
|
|
|
for fingerprint: String in _friend_presence.keys():
|
|
|
|
|
if not _relationships.is_friend(fingerprint):
|
|
|
|
|
_friend_presence.erase(fingerprint)
|
|
|
|
|
friend_presence_updated.emit(get_friend_presence())
|
|
|
|
|
call_deferred("_refresh_social_state")
|
|
|
|
|
|
|
|
|
|
|
2026-08-10 10:04:05 -04:00
|
|
|
func _parse_response_dictionary(body: PackedByteArray) -> Dictionary:
|
|
|
|
|
if body.is_empty():
|
|
|
|
|
return {}
|
|
|
|
|
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
|
|
|
|
return parsed if typeof(parsed) == TYPE_DICTIONARY else {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _request_failure(response: Dictionary) -> String:
|
|
|
|
|
var error: Variant = response.get("error", {})
|
|
|
|
|
if typeof(error) == TYPE_DICTIONARY:
|
2026-08-11 20:31:02 -04:00
|
|
|
var details := error as Dictionary
|
|
|
|
|
if str(details.get("code", "")) == "game_version_mismatch":
|
|
|
|
|
var required_version: String = str(
|
|
|
|
|
details.get("required_game_version", "")
|
|
|
|
|
).strip_edges()
|
|
|
|
|
if not required_version.is_empty():
|
|
|
|
|
return _discovery_version_mismatch_message(required_version)
|
|
|
|
|
var message: String = str(details.get("message", "")).strip_edges()
|
2026-08-10 10:04:05 -04:00
|
|
|
if not message.is_empty():
|
|
|
|
|
return message
|
|
|
|
|
return "Room discovery is temporarily unavailable."
|
|
|
|
|
|
|
|
|
|
|
2026-08-11 20:31:02 -04:00
|
|
|
func _discovery_version_mismatch_message(required_version: String) -> String:
|
|
|
|
|
var local_version: String = NetworkProtocol.game_version()
|
|
|
|
|
var rejection: NetworkProtocol.RejectionCode = (
|
|
|
|
|
NetworkProtocol.game_version_rejection(
|
|
|
|
|
local_version,
|
|
|
|
|
required_version,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
match rejection:
|
|
|
|
|
NetworkProtocol.RejectionCode.CLIENT_OUTDATED:
|
|
|
|
|
return (
|
2026-08-25 17:14:12 -04:00
|
|
|
"Your straywild version is out of date. Update to %s to "
|
2026-08-11 20:31:02 -04:00
|
|
|
+ "enable public discovery. This room will not be listed "
|
|
|
|
|
+ "until you update."
|
|
|
|
|
) % required_version
|
|
|
|
|
NetworkProtocol.RejectionCode.SERVER_OUTDATED:
|
|
|
|
|
return (
|
2026-08-25 17:14:12 -04:00
|
|
|
"Public discovery is still on straywild %s. This room "
|
2026-08-11 20:31:02 -04:00
|
|
|
+ "will not be listed until discovery is updated for %s."
|
|
|
|
|
) % [required_version, local_version]
|
|
|
|
|
_:
|
|
|
|
|
return (
|
2026-08-25 17:14:12 -04:00
|
|
|
"Public discovery requires straywild %s. This room will "
|
2026-08-11 20:31:02 -04:00
|
|
|
+ "not be listed until both versions match."
|
|
|
|
|
) % required_version
|
|
|
|
|
|
|
|
|
|
|
2026-08-10 10:04:05 -04:00
|
|
|
func _on_session_state_changed(state: NetworkSession.State) -> void:
|
feat: expand progression and multiplayer systems
Add named save slots, progression import/export, and a unified play flow. Add live friend requests, presence, invitations, and relationship controls without durable discovery-server social storage. Advance the network protocol with isolated channels, movement reconciliation, late-join recovery, fishing replication, and animation synchronization. Preserve per-species catch totals, refine generated-world startup and water recovery, and complete the related input and interface improvements.
2026-08-23 20:48:38 -04:00
|
|
|
if state == NetworkSession.State.JOINED_CLIENT:
|
|
|
|
|
if not _pending_join_room_id.is_empty():
|
|
|
|
|
_joined_public_room_id = _pending_join_room_id
|
|
|
|
|
elif state in [
|
|
|
|
|
NetworkSession.State.PRIVATE_HOST,
|
|
|
|
|
NetworkSession.State.OPEN_HOST,
|
|
|
|
|
NetworkSession.State.SERVER_LOST,
|
|
|
|
|
NetworkSession.State.CONNECTION_FAILED,
|
|
|
|
|
]:
|
|
|
|
|
_joined_public_room_id = ""
|
|
|
|
|
elif (
|
|
|
|
|
state == NetworkSession.State.INACTIVE
|
|
|
|
|
and not _preserve_pending_join_on_inactive
|
|
|
|
|
):
|
|
|
|
|
_joined_public_room_id = ""
|
2026-08-10 19:49:06 -04:00
|
|
|
if state == NetworkSession.State.CONNECTING and not _pending_join_token.is_empty():
|
2026-08-13 22:20:43 -04:00
|
|
|
_preserve_pending_join_on_inactive = false
|
2026-08-11 10:37:03 -04:00
|
|
|
_set_public_join_state(PublicJoinState.CONNECTING)
|
2026-08-10 19:49:06 -04:00
|
|
|
_join_probe_timer.start()
|
|
|
|
|
call_deferred("_send_pending_join_probe")
|
|
|
|
|
elif state in [
|
|
|
|
|
NetworkSession.State.JOINED_CLIENT,
|
|
|
|
|
NetworkSession.State.SERVER_LOST,
|
|
|
|
|
]:
|
2026-08-13 22:20:43 -04:00
|
|
|
_preserve_pending_join_on_inactive = false
|
2026-08-11 10:37:03 -04:00
|
|
|
_set_public_join_state(PublicJoinState.IDLE)
|
|
|
|
|
_join_probe_timer.stop()
|
|
|
|
|
_clear_pending_join()
|
2026-08-13 22:20:43 -04:00
|
|
|
elif state == NetworkSession.State.INACTIVE:
|
|
|
|
|
if (
|
|
|
|
|
_preserve_pending_join_on_inactive
|
|
|
|
|
and not _pending_join_token.is_empty()
|
|
|
|
|
):
|
|
|
|
|
_preserve_pending_join_on_inactive = false
|
|
|
|
|
else:
|
|
|
|
|
_set_public_join_state(PublicJoinState.IDLE)
|
|
|
|
|
_join_probe_timer.stop()
|
|
|
|
|
_clear_pending_join()
|
2026-08-11 10:37:03 -04:00
|
|
|
elif state == NetworkSession.State.CONNECTION_FAILED:
|
2026-08-13 22:20:43 -04:00
|
|
|
_preserve_pending_join_on_inactive = false
|
2026-08-11 10:37:03 -04:00
|
|
|
_set_public_join_state(PublicJoinState.ERROR)
|
2026-08-10 19:49:06 -04:00
|
|
|
_join_probe_timer.stop()
|
|
|
|
|
_clear_pending_join()
|
2026-08-10 10:04:05 -04:00
|
|
|
if state == NetworkSession.State.OPEN_HOST:
|
|
|
|
|
if _discoverable:
|
|
|
|
|
_heartbeat.start()
|
|
|
|
|
_synchronize_host_lease()
|
|
|
|
|
else:
|
2026-08-11 10:37:03 -04:00
|
|
|
_set_host_state(HostState.PRIVATE)
|
2026-08-10 10:04:05 -04:00
|
|
|
_set_host_status("Room is open but unlisted.", false)
|
|
|
|
|
return
|
|
|
|
|
if state in [
|
|
|
|
|
NetworkSession.State.PRIVATE_HOST,
|
|
|
|
|
NetworkSession.State.INACTIVE,
|
|
|
|
|
NetworkSession.State.DISCONNECTING,
|
|
|
|
|
NetworkSession.State.CONNECTION_FAILED,
|
|
|
|
|
NetworkSession.State.SERVER_LOST,
|
|
|
|
|
]:
|
|
|
|
|
if _discoverable:
|
|
|
|
|
_discoverable = false
|
|
|
|
|
host_settings_changed.emit(_room_name, false)
|
|
|
|
|
_heartbeat.stop()
|
2026-08-11 10:37:03 -04:00
|
|
|
_upnp_renew_timer.stop()
|
2026-08-10 10:04:05 -04:00
|
|
|
_remove_host_lease()
|
2026-08-11 10:37:03 -04:00
|
|
|
_begin_upnp_cleanup()
|
2026-08-10 10:04:05 -04:00
|
|
|
if state == NetworkSession.State.PRIVATE_HOST:
|
2026-08-11 10:37:03 -04:00
|
|
|
_set_host_state(HostState.CLOSED)
|
2026-08-10 10:04:05 -04:00
|
|
|
_set_host_status("Open the game before listing it publicly.", false)
|
2026-08-11 10:37:03 -04:00
|
|
|
else:
|
|
|
|
|
_set_host_state(HostState.CLOSED)
|
feat: expand progression and multiplayer systems
Add named save slots, progression import/export, and a unified play flow. Add live friend requests, presence, invitations, and relationship controls without durable discovery-server social storage. Advance the network protocol with isolated channels, movement reconciliation, late-join recovery, fishing replication, and animation synchronization. Preserve per-species catch totals, refine generated-world startup and water recovery, and complete the related input and interface improvements.
2026-08-23 20:48:38 -04:00
|
|
|
call_deferred("_refresh_social_state")
|
2026-08-10 10:04:05 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
func _on_host_openness_changed(is_open: bool) -> void:
|
|
|
|
|
if not is_open and _discoverable:
|
|
|
|
|
set_discoverable(false)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _on_peer_count_changed(_player_count: int, _max_players: int) -> void:
|
|
|
|
|
if _discoverable:
|
|
|
|
|
_synchronize_host_lease()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _clear_lease() -> void:
|
|
|
|
|
_lease_room_id = ""
|
|
|
|
|
_lease_token = ""
|
2026-08-10 19:49:06 -04:00
|
|
|
_host_verified = false
|
|
|
|
|
_host_verification_token = ""
|
|
|
|
|
_traversal_timer.stop()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _clear_pending_join() -> void:
|
|
|
|
|
_pending_join_endpoint = ""
|
|
|
|
|
_pending_join_token = ""
|
|
|
|
|
_pending_join_room_id = ""
|
|
|
|
|
_join_probe_timer.stop()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _exit_tree() -> void:
|
2026-08-11 10:37:03 -04:00
|
|
|
_upnp_renew_timer.stop()
|
2026-08-10 19:49:06 -04:00
|
|
|
if _upnp_thread != null:
|
|
|
|
|
var result: Variant = _upnp_thread.wait_to_finish()
|
|
|
|
|
_upnp_thread = null
|
|
|
|
|
if typeof(result) == TYPE_DICTIONARY:
|
|
|
|
|
var details: Dictionary = result
|
|
|
|
|
if bool(details.get("success", false)):
|
|
|
|
|
_upnp = details.get("upnp") as UPNP
|
|
|
|
|
_upnp_mapped_port = int(details.get("port", 0))
|
|
|
|
|
_begin_upnp_cleanup()
|
2026-08-10 10:04:05 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
func _set_host_status(message: String, is_error: bool) -> void:
|
|
|
|
|
_host_status_message = message
|
|
|
|
|
_host_status_is_error = is_error
|
|
|
|
|
host_status_changed.emit(message, is_error)
|
|
|
|
|
|
|
|
|
|
|
2026-08-11 10:37:03 -04:00
|
|
|
func _set_host_state(state: HostState) -> void:
|
|
|
|
|
if _host_state == state:
|
|
|
|
|
return
|
|
|
|
|
_host_state = state
|
|
|
|
|
host_state_changed.emit(int(state))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _set_public_join_state(state: PublicJoinState) -> void:
|
|
|
|
|
if _public_join_state == state:
|
|
|
|
|
return
|
|
|
|
|
_public_join_state = state
|
|
|
|
|
public_join_state_changed.emit(int(state))
|
|
|
|
|
|
|
|
|
|
|
feat: expand progression and multiplayer systems
Add named save slots, progression import/export, and a unified play flow. Add live friend requests, presence, invitations, and relationship controls without durable discovery-server social storage. Advance the network protocol with isolated channels, movement reconciliation, late-join recovery, fishing replication, and animation synchronization. Preserve per-species catch totals, refine generated-world startup and water recovery, and complete the related input and interface improvements.
2026-08-23 20:48:38 -04:00
|
|
|
func _empty_rooms() -> Array[Dictionary]:
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
2026-08-10 10:04:05 -04:00
|
|
|
func _sanitize_room_name(value: String) -> String:
|
|
|
|
|
var cleaned: String = value.strip_edges().replace("\n", " ").replace("\r", " ")
|
|
|
|
|
cleaned = cleaned.replace("\t", " ")
|
|
|
|
|
while " " in cleaned:
|
|
|
|
|
cleaned = cleaned.replace(" ", " ")
|
|
|
|
|
return cleaned.left(MAX_ROOM_NAME_LENGTH)
|
|
|
|
|
|
|
|
|
|
|
2026-08-11 16:35:33 -04:00
|
|
|
func _default_room_name(player_name: String) -> String:
|
|
|
|
|
var cleaned_name: String = player_name.strip_edges()
|
|
|
|
|
if cleaned_name.is_empty():
|
|
|
|
|
return DEFAULT_ROOM_NAME
|
|
|
|
|
return "%s%s" % [
|
|
|
|
|
cleaned_name.left(MAX_ROOM_NAME_LENGTH - ROOM_NAME_SUFFIX.length()),
|
|
|
|
|
ROOM_NAME_SUFFIX,
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
2026-08-10 10:04:05 -04:00
|
|
|
func _load_settings() -> void:
|
|
|
|
|
var config := ConfigFile.new()
|
|
|
|
|
if config.load(SETTINGS_PATH) == OK:
|
|
|
|
|
var saved_name: String = _sanitize_room_name(
|
|
|
|
|
str(config.get_value("host", "room_name", DEFAULT_ROOM_NAME))
|
|
|
|
|
)
|
|
|
|
|
if not saved_name.is_empty():
|
|
|
|
|
_room_name = saved_name
|
feat: expand progression and multiplayer systems
Add named save slots, progression import/export, and a unified play flow. Add live friend requests, presence, invitations, and relationship controls without durable discovery-server social storage. Advance the network protocol with isolated channels, movement reconciliation, late-join recovery, fishing replication, and animation synchronization. Preserve per-species catch totals, refine generated-world startup and water recovery, and complete the related input and interface improvements.
2026-08-23 20:48:38 -04:00
|
|
|
_room_name_uses_default = bool(config.get_value(
|
2026-08-11 16:35:33 -04:00
|
|
|
"host",
|
|
|
|
|
"room_name_uses_default",
|
|
|
|
|
saved_name in [
|
|
|
|
|
DEFAULT_ROOM_NAME,
|
|
|
|
|
LEGACY_DEFAULT_ROOM_NAME,
|
|
|
|
|
LEGACY_DEDICATED_DEFAULT_ROOM_NAME,
|
|
|
|
|
],
|
feat: expand progression and multiplayer systems
Add named save slots, progression import/export, and a unified play flow. Add live friend requests, presence, invitations, and relationship controls without durable discovery-server social storage. Advance the network protocol with isolated channels, movement reconciliation, late-join recovery, fishing replication, and animation synchronization. Preserve per-species catch totals, refine generated-world startup and water recovery, and complete the related input and interface improvements.
2026-08-23 20:48:38 -04:00
|
|
|
))
|
|
|
|
|
_presence_sharing = bool(config.get_value(
|
|
|
|
|
"social", "share_presence", false
|
2026-08-11 16:35:33 -04:00
|
|
|
))
|
2026-08-10 10:04:05 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
func _save_settings() -> void:
|
2026-08-11 22:58:33 -04:00
|
|
|
if not _host_settings_persistence_enabled:
|
|
|
|
|
return
|
2026-08-10 10:04:05 -04:00
|
|
|
var config := ConfigFile.new()
|
|
|
|
|
config.set_value("host", "room_name", _room_name)
|
2026-08-11 16:35:33 -04:00
|
|
|
config.set_value(
|
|
|
|
|
"host", "room_name_uses_default", _room_name_uses_default
|
|
|
|
|
)
|
feat: expand progression and multiplayer systems
Add named save slots, progression import/export, and a unified play flow. Add live friend requests, presence, invitations, and relationship controls without durable discovery-server social storage. Advance the network protocol with isolated channels, movement reconciliation, late-join recovery, fishing replication, and animation synchronization. Preserve per-species catch totals, refine generated-world startup and water recovery, and complete the related input and interface improvements.
2026-08-23 20:48:38 -04:00
|
|
|
config.set_value("social", "share_presence", _presence_sharing)
|
2026-08-10 10:04:05 -04:00
|
|
|
var error: Error = config.save(SETTINGS_PATH)
|
|
|
|
|
if error != OK:
|
2026-08-25 17:14:12 -04:00
|
|
|
push_warning("Could not save local straywild discovery settings.")
|