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.
This commit is contained in:
Alexander Sellite 2026-08-23 20:48:38 -04:00
parent 1db1a5b754
commit 3b84bfe3a0
97 changed files with 7869 additions and 982 deletions

View file

@ -3,6 +3,15 @@ extends Node
signal entries_changed
signal moderation_finished(success: bool, message: String)
signal friend_request_received(
request_id: String,
fingerprint: String,
display_name: String,
)
signal friend_action_finished(success: bool, message: String)
const MAX_PENDING_FRIEND_REQUESTS := 16
const FRIEND_REQUEST_ID_LENGTH := 32
var _session: NetworkSession
var _relationships: PlayerRelationshipStore
@ -18,6 +27,9 @@ var _peer_fingerprints: Dictionary[int, String] = {}
var _remote_bans: Array[Dictionary] = []
var _ban_snapshot_requested: bool = false
var _ban_snapshot_loaded: bool = false
var _pending_outgoing_friends: Dictionary[String, Dictionary] = {}
var _pending_incoming_friends: Dictionary[String, Dictionary] = {}
var _host_friend_routes: Dictionary[String, Dictionary] = {}
func setup(
@ -74,6 +86,18 @@ func get_entries() -> Array[PlayerListEntry]:
entry.ping_to_host_ms = _session.get_peer_rtt_ms(peer_id)
entry.muted = _relationships.is_muted(record.identity_fingerprint)
entry.blocked = blocked
entry.is_friend = _relationships.is_friend(record.identity_fingerprint)
entry.can_request_friend = (
not entry.is_local_player
and not entry.is_friend
and not entry.blocked
and _session.supports_server_capability(
NetworkProtocol.FRIENDS_CAPABILITY
)
and _session.peer_supports_capability(
peer_id, NetworkProtocol.FRIENDS_CAPABILITY
)
)
entry.can_kick = (
_session.can_local_moderate()
and peer_id != local_id
@ -125,6 +149,10 @@ func get_relationships() -> Array[Dictionary]:
return _relationships.get_records()
func get_friends() -> Array[Dictionary]:
return _relationships.get_friends()
func get_bans() -> Array[Dictionary]:
if _session.is_host():
return _bans.get_bans(_session.get_host_identity_fingerprint())
@ -180,6 +208,160 @@ func set_blocked(fingerprint: String, display_name: String, value: bool) -> bool
return _relationships.set_blocked(fingerprint, display_name, value)
func remove_friend(fingerprint: String, display_name: String) -> bool:
if fingerprint == _session.get_local_identity_fingerprint():
return false
var ok := _relationships.remove_friend(fingerprint, display_name)
friend_action_finished.emit(
ok,
"Friend removed." if ok else "Friend could not be removed.",
)
return ok
func send_friend_request(
peer_id: int,
fingerprint: String,
display_name: String,
) -> bool:
if (
_pending_outgoing_friends.size() >= MAX_PENDING_FRIEND_REQUESTS
or fingerprint == _session.get_local_identity_fingerprint()
or _relationships.is_blocked(fingerprint)
or _relationships.is_friend(fingerprint)
or not _valid_friend_target(peer_id, fingerprint)
):
friend_action_finished.emit(
false, "This person needs to be online to do this."
)
return false
var local_capabilities: Dictionary = (
_relationships.create_friend_capabilities()
)
if local_capabilities.is_empty():
friend_action_finished.emit(false, "Friend request could not be created.")
return false
var request_id := NetworkIdentityCrypto.secure_id(16)
_pending_outgoing_friends[request_id] = {
"target_peer_id": peer_id,
"target_fingerprint": fingerprint,
"target_display_name": display_name,
"local_capabilities": local_capabilities,
}
var public_capabilities := _public_friend_capabilities(local_capabilities)
if _session.is_host():
_route_friend_request(
_session.get_local_peer_id(), peer_id, request_id, public_capabilities
)
else:
request_friendship.rpc_id(
1, peer_id, request_id, public_capabilities
)
friend_action_finished.emit(true, "Friend request sent.")
return true
func respond_friend_request(request_id: String, accepted: bool) -> bool:
var pending: Dictionary = _pending_incoming_friends.get(request_id, {})
if pending.is_empty():
friend_action_finished.emit(
false, "This friend request is no longer available."
)
return false
_pending_incoming_friends.erase(request_id)
var local_capabilities: Dictionary = {}
var public_capabilities: Dictionary = {}
var response_accepted := accepted
if accepted:
local_capabilities = _relationships.create_friend_capabilities()
response_accepted = (
not local_capabilities.is_empty()
and _relationships.add_friend(
str(pending["requester_fingerprint"]),
str(pending["requester_display_name"]),
local_capabilities,
pending["remote_capabilities"],
)
)
if response_accepted:
public_capabilities = _public_friend_capabilities(local_capabilities)
if _session.is_host():
_route_friend_response(
_session.get_local_peer_id(),
request_id,
response_accepted,
public_capabilities,
)
else:
respond_friendship.rpc_id(
1, request_id, response_accepted, public_capabilities
)
friend_action_finished.emit(
response_accepted or not accepted,
"Friend added."
if response_accepted
else "Friend request declined."
if not accepted
else "Friend could not be saved.",
)
return response_accepted or not accepted
@rpc("any_peer", "call_remote", "reliable", 0)
func request_friendship(
target_peer_id: int,
request_id: String,
public_capabilities: Dictionary,
) -> void:
_route_friend_request(
multiplayer.get_remote_sender_id(),
target_peer_id,
request_id,
public_capabilities,
)
@rpc("authority", "call_remote", "reliable", 0)
func receive_friend_request(
request_id: String,
requester_fingerprint: String,
requester_display_name: String,
public_capabilities: Dictionary,
) -> void:
_receive_friend_request_local(
request_id,
requester_fingerprint,
requester_display_name,
public_capabilities,
)
@rpc("any_peer", "call_remote", "reliable", 0)
func respond_friendship(
request_id: String,
accepted: bool,
public_capabilities: Dictionary,
) -> void:
_route_friend_response(
multiplayer.get_remote_sender_id(),
request_id,
accepted,
public_capabilities,
)
@rpc("authority", "call_remote", "reliable", 0)
func receive_friend_result(
request_id: String,
accepted: bool,
public_capabilities: Dictionary,
message: String,
) -> void:
_receive_friend_result_local(
request_id, accepted, public_capabilities, message
)
func kick(peer_id: int, fingerprint: String, revision: int) -> bool:
if _session.is_host():
return _kick_on_host(peer_id, fingerprint, revision, true)
@ -454,6 +636,32 @@ func _on_peer_removed(peer_id: int) -> void:
for key: String in _host_block_pairs.keys():
if fingerprint in key.split(":"):
_host_block_pairs.erase(key)
for request_id: String in _host_friend_routes.keys():
var route: Dictionary = _host_friend_routes[request_id]
if int(route.get("target_peer_id", 0)) == peer_id:
_send_friend_result_to_peer(
int(route.get("requester_peer_id", 0)),
request_id,
false,
{},
"This person needs to be online to do this.",
)
if (
int(route.get("target_peer_id", 0)) == peer_id
or int(route.get("requester_peer_id", 0)) == peer_id
):
_host_friend_routes.erase(request_id)
for request_id: String in _pending_outgoing_friends.keys():
var pending: Dictionary = _pending_outgoing_friends[request_id]
if str(pending.get("target_fingerprint", "")) == fingerprint:
_pending_outgoing_friends.erase(request_id)
friend_action_finished.emit(
false, "This person needs to be online to do this."
)
for request_id: String in _pending_incoming_friends.keys():
var pending: Dictionary = _pending_incoming_friends[request_id]
if str(pending.get("requester_fingerprint", "")) == fingerprint:
_pending_incoming_friends.erase(request_id)
_changed()
@ -469,6 +677,9 @@ func _on_session_state_changed(state: NetworkSession.State) -> void:
_remote_bans.clear()
_ban_snapshot_requested = false
_ban_snapshot_loaded = false
_pending_outgoing_friends.clear()
_pending_incoming_friends.clear()
_host_friend_routes.clear()
elif state == NetworkSession.State.JOINED_CLIENT:
_request_ban_snapshot()
_changed()
@ -506,6 +717,245 @@ func _peer_for_fingerprint(fingerprint: String) -> int:
return 0
func _valid_friend_target(peer_id: int, fingerprint: String) -> bool:
if (
not _session.is_gameplay_session_active()
or not _session.supports_server_capability(
NetworkProtocol.FRIENDS_CAPABILITY
)
or not _session.peer_supports_capability(
peer_id, NetworkProtocol.FRIENDS_CAPABILITY
)
):
return false
var record: PeerRegistry.PeerRecord = _session.get_peer_record(peer_id)
return (
record != null
and record.identity_authenticated
and record.identity_fingerprint == fingerprint
)
func _route_friend_request(
requester_peer_id: int,
target_peer_id: int,
request_id: String,
public_capabilities: Dictionary,
) -> void:
if (
not _session.is_host()
or not _valid_friend_request_id(request_id)
or not _valid_public_friend_capabilities(public_capabilities)
or not _session.is_authenticated_peer(requester_peer_id)
):
return
var requester: PeerRegistry.PeerRecord = (
_session.get_peer_record(requester_peer_id)
)
var target: PeerRegistry.PeerRecord = _session.get_peer_record(target_peer_id)
var can_route := (
requester != null
and target != null
and requester_peer_id != target_peer_id
and _valid_friend_target(
target_peer_id, target.identity_fingerprint
)
and _session.peer_supports_capability(
requester_peer_id, NetworkProtocol.FRIENDS_CAPABILITY
)
and not pair_is_blocked(
requester.identity_fingerprint, target.identity_fingerprint
)
and _host_friend_routes.size() < (
MAX_PENDING_FRIEND_REQUESTS * 8
)
)
if not can_route:
_send_friend_result_to_peer(
requester_peer_id,
request_id,
false,
{},
"This person needs to be online to do this.",
)
return
var requester_pending_count := 0
for route: Dictionary in _host_friend_routes.values():
if int(route.get("requester_peer_id", 0)) == requester_peer_id:
requester_pending_count += 1
if requester_pending_count >= MAX_PENDING_FRIEND_REQUESTS:
_send_friend_result_to_peer(
requester_peer_id,
request_id,
false,
{},
"Too many friend requests are pending.",
)
return
_host_friend_routes[request_id] = {
"requester_peer_id": requester_peer_id,
"target_peer_id": target_peer_id,
}
if target_peer_id == _session.get_local_peer_id():
_receive_friend_request_local(
request_id,
requester.identity_fingerprint,
requester.display_name,
public_capabilities,
)
else:
receive_friend_request.rpc_id(
target_peer_id,
request_id,
requester.identity_fingerprint,
requester.display_name,
public_capabilities,
)
func _receive_friend_request_local(
request_id: String,
requester_fingerprint: String,
requester_display_name: String,
public_capabilities: Dictionary,
) -> void:
if (
not _valid_friend_request_id(request_id)
or not _valid_public_friend_capabilities(public_capabilities)
or not NetworkIdentityCrypto.valid_fingerprint(requester_fingerprint)
or not NetworkProfilePreferences.is_valid_display_name(
requester_display_name
)
or _pending_incoming_friends.size()
>= MAX_PENDING_FRIEND_REQUESTS
or _relationships.is_blocked(requester_fingerprint)
or _relationships.is_friend(requester_fingerprint)
):
_decline_friend_request(request_id)
return
_pending_incoming_friends[request_id] = {
"requester_fingerprint": requester_fingerprint,
"requester_display_name": requester_display_name,
"remote_capabilities": public_capabilities.duplicate(true),
}
friend_request_received.emit(
request_id, requester_fingerprint, requester_display_name
)
func _decline_friend_request(request_id: String) -> void:
if _session.is_host():
_route_friend_response(
_session.get_local_peer_id(), request_id, false, {}
)
elif _session.is_joined_client():
respond_friendship.rpc_id(1, request_id, false, {})
func _route_friend_response(
responder_peer_id: int,
request_id: String,
accepted: bool,
public_capabilities: Dictionary,
) -> void:
if not _session.is_host():
return
var route: Dictionary = _host_friend_routes.get(request_id, {})
if (
route.is_empty()
or int(route.get("target_peer_id", 0)) != responder_peer_id
):
return
_host_friend_routes.erase(request_id)
var response_accepted := (
accepted and _valid_public_friend_capabilities(public_capabilities)
)
_send_friend_result_to_peer(
int(route["requester_peer_id"]),
request_id,
response_accepted,
public_capabilities if response_accepted else {},
"Friend request accepted."
if response_accepted
else "Friend request declined."
if not accepted
else "Friend request could not be completed.",
)
func _send_friend_result_to_peer(
peer_id: int,
request_id: String,
accepted: bool,
public_capabilities: Dictionary,
message: String,
) -> void:
if peer_id == _session.get_local_peer_id():
_receive_friend_result_local(
request_id, accepted, public_capabilities, message
)
elif _session.is_authenticated_peer(peer_id):
receive_friend_result.rpc_id(
peer_id,
request_id,
accepted,
public_capabilities,
message.left(120),
)
func _receive_friend_result_local(
request_id: String,
accepted: bool,
public_capabilities: Dictionary,
message: String,
) -> void:
var pending: Dictionary = _pending_outgoing_friends.get(request_id, {})
if pending.is_empty():
return
_pending_outgoing_friends.erase(request_id)
var success := false
if accepted and _valid_public_friend_capabilities(public_capabilities):
success = _relationships.add_friend(
str(pending["target_fingerprint"]),
str(pending["target_display_name"]),
pending["local_capabilities"],
public_capabilities,
)
message = "Friend added." if success else "Friend could not be saved."
friend_action_finished.emit(success, message.left(120))
func _public_friend_capabilities(capabilities: Dictionary) -> Dictionary:
return {
"presence_channel": str(capabilities.get("presence_channel", "")),
"invite_token": str(capabilities.get("invite_token", "")),
}
func _valid_public_friend_capabilities(capabilities: Dictionary) -> bool:
return (
_valid_friend_hex(str(capabilities.get("presence_channel", "")))
and _valid_friend_hex(str(capabilities.get("invite_token", "")))
)
func _valid_friend_request_id(request_id: String) -> bool:
return _valid_friend_hex(request_id, FRIEND_REQUEST_ID_LENGTH)
func _valid_friend_hex(
value: String,
expected_length: int = PlayerRelationshipStore.SOCIAL_TOKEN_LENGTH,
) -> bool:
if value.length() != expected_length:
return false
for character: String in value:
if character not in "0123456789abcdef":
return false
return true
func _valid_moderation_target(
peer_id: int,
fingerprint: String,