Add players page and host moderation

This commit is contained in:
Alexander Sellite 2026-07-29 23:10:51 -04:00
parent fa996a6735
commit 141b54261d
23 changed files with 1166 additions and 31 deletions

132
network/host_ban_store.gd Normal file
View file

@ -0,0 +1,132 @@
class_name HostBanStore
extends Node
signal bans_changed
const FORMAT_VERSION := 1
const STORE_PATH := "user://host_bans.json"
const TEMP_PATH := STORE_PATH + ".tmp"
const MAX_BANS := 500
var _namespaces: Dictionary = {}
var _loaded := false
var _write_blocked := false
func is_banned(host_fingerprint: String, target_fingerprint: String) -> bool:
_ensure_loaded()
return Dictionary(_namespaces.get(host_fingerprint, {})).has(target_fingerprint)
func ban(host_fingerprint: String, target_fingerprint: String, name: String) -> bool:
if (
not NetworkIdentityCrypto.valid_fingerprint(host_fingerprint)
or not NetworkIdentityCrypto.valid_fingerprint(target_fingerprint)
or not NetworkProfilePreferences.is_valid_display_name(name)
):
return false
_ensure_loaded()
if _write_blocked:
return false
var previous_namespace: Dictionary = Dictionary(
_namespaces.get(host_fingerprint, {})
).duplicate(true)
var records: Dictionary = _namespaces.get(host_fingerprint, {})
records[target_fingerprint] = {
"host_fingerprint": host_fingerprint,
"target_fingerprint": target_fingerprint,
"last_known_display_name": name,
"banned_unix": int(Time.get_unix_time_from_system()),
"reason": "host_ban",
}
while records.size() > MAX_BANS:
records.erase(records.keys().front())
_namespaces[host_fingerprint] = records
if not _save():
_namespaces[host_fingerprint] = previous_namespace
return false
bans_changed.emit()
return true
func unban(host_fingerprint: String, target_fingerprint: String) -> bool:
_ensure_loaded()
if _write_blocked:
return false
var previous_namespace: Dictionary = Dictionary(
_namespaces.get(host_fingerprint, {})
).duplicate(true)
var records: Dictionary = _namespaces.get(host_fingerprint, {})
records.erase(target_fingerprint)
_namespaces[host_fingerprint] = records
if not _save():
_namespaces[host_fingerprint] = previous_namespace
return false
bans_changed.emit()
return true
func get_bans(host_fingerprint: String) -> Array[Dictionary]:
_ensure_loaded()
var result: Array[Dictionary] = []
for value: Dictionary in Dictionary(_namespaces.get(host_fingerprint, {})).values():
result.append(value.duplicate(true))
result.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return str(a["last_known_display_name"]).naturalnocasecmp_to(
str(b["last_known_display_name"])
) < 0
)
return result
func _ensure_loaded() -> void:
if _loaded:
return
_loaded = true
if not FileAccess.file_exists(STORE_PATH):
return
var file := FileAccess.open(STORE_PATH, FileAccess.READ)
if file == null:
return
var json := JSON.new()
if json.parse(file.get_as_text()) != OK or typeof(json.data) != TYPE_DICTIONARY:
return
var data: Dictionary = json.data
if data.get("format_version") != FORMAT_VERSION:
_write_blocked = true
return
for value: Variant in data.get("records", []):
if typeof(value) != TYPE_DICTIONARY:
continue
var record: Dictionary = value
var host := str(record.get("host_fingerprint", ""))
var target := str(record.get("target_fingerprint", ""))
if not NetworkIdentityCrypto.valid_fingerprint(host) or not NetworkIdentityCrypto.valid_fingerprint(target):
continue
var records: Dictionary = _namespaces.get(host, {})
records[target] = record.duplicate(true)
_namespaces[host] = records
func _save() -> bool:
var values: Array = []
for records: Dictionary in _namespaces.values():
values.append_array(records.values())
var file := FileAccess.open(TEMP_PATH, FileAccess.WRITE)
if file == null:
return false
file.store_string(JSON.stringify({
"format_version": FORMAT_VERSION,
"records": values,
}, "\t"))
file.flush()
var ok := file.get_error() == OK
file.close()
if not ok:
return false
if FileAccess.file_exists(STORE_PATH):
DirAccess.remove_absolute(ProjectSettings.globalize_path(STORE_PATH))
return DirAccess.rename_absolute(
ProjectSettings.globalize_path(TEMP_PATH),
ProjectSettings.globalize_path(STORE_PATH),
) == OK

View file

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

View file

@ -16,6 +16,7 @@ var _request_ledgers: Dictionary[int, Dictionary] = {}
var _rate_times: Dictionary[int, Array] = {}
var _sequence: int = 0
var _peer_names: Dictionary[int, String] = {}
var _relationships: PlayerRelationshipStore
func setup(session: NetworkSession) -> void:
@ -29,6 +30,21 @@ func setup(session: NetworkSession) -> void:
)
func set_relationship_store(store: PlayerRelationshipStore) -> void:
_relationships = store
func refresh_relationship_filters() -> void:
history_replaced.emit(get_history())
func is_sender_filtered(fingerprint: String) -> bool:
return (
_relationships != null
and _relationships.is_muted(fingerprint)
)
func send_local_message(body: String) -> bool:
if (
_session == null
@ -63,7 +79,11 @@ func send_local_message(body: String) -> bool:
func get_history() -> Array[Dictionary]:
return _history.duplicate(true)
var result: Array[Dictionary] = []
for message: Dictionary in _history:
if _message_is_visible(message):
result.append(message.duplicate(true))
return result
@rpc("any_peer", "call_remote", "reliable", NetworkChatProtocol.RELIABLE_CHANNEL)
@ -193,7 +213,8 @@ func _apply_message(data: Dictionary) -> void:
_history.append(data.duplicate(true))
while _history.size() > NetworkChatProtocol.MAX_HISTORY:
_history.pop_front()
message_received.emit(data.duplicate(true))
if _message_is_visible(data):
message_received.emit(data.duplicate(true))
func _send_rejection(peer_id: int, message: String) -> void:
@ -240,7 +261,17 @@ func receive_chat_history(values: Array) -> void:
for value: Variant in values:
if NetworkChatProtocol.validate_message(value):
_apply_message(value)
history_replaced.emit(_history.duplicate(true))
history_replaced.emit(get_history())
func _message_is_visible(message: Dictionary) -> bool:
if int(message.get("kind", -1)) == NetworkChatProtocol.Kind.SYSTEM:
return true
if _relationships == null:
return true
return not _relationships.is_muted(
str(message.get("sender_fingerprint", ""))
)
func _consume_rate(peer_id: int) -> bool:

View file

@ -28,6 +28,7 @@ var _pending_local_send: Dictionary = {}
var _pending_transfers: Dictionary[String, Dictionary] = {}
var _local_removal_snapshots: Dictionary[String, Dictionary] = {}
var _received_awards: Dictionary[String, bool] = {}
var _relationship_policy: NetworkPlayerListService
func setup(
@ -62,10 +63,22 @@ func setup(
_session.state_changed.connect(_on_session_state_changed)
func set_relationship_policy(policy: NetworkPlayerListService) -> void:
_relationship_policy = policy
func refresh_relationship_filters() -> void:
_emit_mailbox()
peers_changed.emit()
func get_local_letters() -> Array[Dictionary]:
var values: Array[Dictionary] = []
for letter: Dictionary in _local_letters.values():
if int(letter["recipient_peer_id"]) == _session.get_local_peer_id():
if (
int(letter["recipient_peer_id"]) == _session.get_local_peer_id()
and not _letter_is_locally_blocked(letter)
):
values.append(letter.duplicate(true))
values.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
var a_unread := int(a["state"]) == NetworkMailProtocol.State.SENT_UNREAD
@ -87,6 +100,7 @@ func get_unread_count() -> int:
if (
int(letter["recipient_peer_id"]) == _session.get_local_peer_id()
and int(letter["state"]) == NetworkMailProtocol.State.SENT_UNREAD
and not _letter_is_locally_blocked(letter)
):
count += 1
return count
@ -111,7 +125,12 @@ func get_recipient_choices() -> Array[Dictionary]:
if peer_id == local_id:
continue
var record := _session.get_peer_record(peer_id)
if record != null:
if record != null and (
_relationship_policy == null
or not _relationship_policy.is_locally_blocked(
record.identity_fingerprint
)
):
choices.append({
"peer_id": peer_id,
"name": record.display_name,
@ -148,6 +167,12 @@ func send_letter(
or not _session.is_gameplay_session_active()
or recipient_peer_id == _session.get_local_peer_id()
or not _session.is_authenticated_peer(recipient_peer_id)
or (
_relationship_policy != null
and _relationship_policy.is_locally_blocked(
_session.get_peer_record(recipient_peer_id).identity_fingerprint
)
)
or (
not _session.is_host()
and not _session.supports_server_capability(
@ -235,6 +260,15 @@ func _handle_send(sender: int, data: Dictionary) -> void:
)
):
error = "Letter identity could not be verified."
if (
error.is_empty()
and _relationship_policy != null
and _relationship_policy.pair_is_blocked(
sender_record.identity_fingerprint,
recipient_record.identity_fingerprint,
)
):
error = "That player is unavailable."
if error.is_empty() and _letters.size() >= NetworkMailProtocol.MAX_SESSION_LETTERS:
error = "The session mailbox is full."
var recipient_count := 0
@ -327,6 +361,27 @@ func _receive_letter(letter: Dictionary) -> void:
_emit_mailbox()
func cancel_unaccepted_between(first: String, second: String) -> void:
if not _session.is_host():
return
for letter: Dictionary in _letters.values():
if not (
str(letter.get("sender_fingerprint", "")) in [first, second]
and str(letter.get("recipient_fingerprint", "")) in [first, second]
):
continue
if int(letter.get("state", -1)) not in [
NetworkMailProtocol.State.SENT_UNREAD,
NetworkMailProtocol.State.READ,
]:
continue
letter["state"] = NetworkMailProtocol.State.CANCELLED
_request_release(
int(letter["sender_peer_id"]), str(letter.get("reservation_id", ""))
)
_update_participants(letter)
func _deliver_send_result(peer_id: int, result: Dictionary) -> void:
if peer_id == _session.get_local_peer_id():
_receive_send_result(result)
@ -872,6 +927,18 @@ func _emit_mailbox() -> void:
unread_count_changed.emit(get_unread_count())
func _letter_is_locally_blocked(letter: Dictionary) -> bool:
if _relationship_policy == null:
return false
var local_id := _session.get_local_peer_id()
var other_fingerprint := (
str(letter.get("sender_fingerprint", ""))
if int(letter.get("sender_peer_id", 0)) != local_id
else str(letter.get("recipient_fingerprint", ""))
)
return _relationship_policy.is_locally_blocked(other_fingerprint)
func _on_peer_removed(peer_id: int) -> void:
if not _session.is_host():
_clear_local_mail_for_peer(peer_id)

View file

@ -0,0 +1,266 @@
class_name NetworkPlayerListService
extends Node
signal entries_changed
signal moderation_finished(success: bool, message: String)
var _session: NetworkSession
var _relationships: PlayerRelationshipStore
var _bans: HostBanStore
var _known: KnownPlayerStore
var _spawn: PlayerSpawnService
var _chat: NetworkChatService
var _mail: NetworkMailService
var _revision := 0
var _host_block_pairs: Dictionary[String, bool] = {}
var _peer_fingerprints: Dictionary[int, String] = {}
func setup(
session: NetworkSession,
relationships: PlayerRelationshipStore,
bans: HostBanStore,
known: KnownPlayerStore,
spawn: PlayerSpawnService,
chat: NetworkChatService,
mail: NetworkMailService,
) -> void:
_session = session
_relationships = relationships
_bans = bans
_known = known
_spawn = spawn
_chat = chat
_mail = mail
_session.peer_authenticated.connect(_on_peer_authenticated)
_session.peer_removed.connect(_on_peer_removed)
_session.peer_display_name_changed.connect(func(_id: int, _name: String) -> void: _changed())
_session.peer_count_changed.connect(
func(_count: int, _maximum: int) -> void: _sync_authenticated_peers()
)
_session.state_changed.connect(_on_session_state_changed)
_relationships.relationship_changed.connect(_on_relationship_changed)
_bans.bans_changed.connect(_changed)
_chat.set_relationship_store(_relationships)
_mail.set_relationship_policy(self)
for peer_id: int in _session.get_authenticated_peer_ids():
_on_peer_authenticated(peer_id, "")
func get_entries() -> Array[PlayerListEntry]:
var result: Array[PlayerListEntry] = []
var local_id := _session.get_local_peer_id()
for peer_id: int in _session.get_authenticated_peer_ids():
var record := _session.get_peer_record(peer_id)
if record == null or not record.identity_authenticated:
continue
var blocked := _relationships.is_blocked(record.identity_fingerprint)
if blocked and peer_id != local_id:
continue
var entry := PlayerListEntry.new()
entry.peer_id = peer_id
entry.full_fingerprint = record.identity_fingerprint
entry.compact_fingerprint = NetworkIdentityCrypto.compact_suffix(record.identity_fingerprint)
entry.display_name = record.display_name
entry.is_host = peer_id == 1
entry.is_local_player = peer_id == local_id
entry.continuity_state = _known.identity_status(record.identity_fingerprint, record.display_name)
entry.ping_to_host_ms = _session.get_peer_rtt_ms(peer_id)
entry.muted = _relationships.is_muted(record.identity_fingerprint)
entry.blocked = blocked
entry.can_kick = _session.is_host() and peer_id != local_id and peer_id != 1
entry.can_ban = entry.can_kick
entry.revision = _revision
result.append(entry)
result.sort_custom(_entry_before)
return result
func get_connected_count() -> int:
return _session.get_player_count()
func get_max_players() -> int:
return _session.get_session_max_players()
func is_local_host() -> bool:
return _session.is_host()
func get_relationships() -> Array[Dictionary]:
return _relationships.get_records()
func get_bans() -> Array[Dictionary]:
return _bans.get_bans(_session.get_host_identity_fingerprint()) if _session.is_host() else []
func set_muted(fingerprint: String, display_name: String, value: bool) -> bool:
if fingerprint == _session.get_local_identity_fingerprint():
return false
return _relationships.set_muted(fingerprint, display_name, value)
func set_blocked(fingerprint: String, display_name: String, value: bool) -> bool:
if fingerprint == _session.get_local_identity_fingerprint():
return false
return _relationships.set_blocked(fingerprint, display_name, value)
func kick(peer_id: int, fingerprint: String, revision: int) -> bool:
if not _valid_moderation_target(peer_id, fingerprint, revision):
moderation_finished.emit(false, "That player is no longer connected.")
return false
var ok := _session.kick_authenticated_peer(peer_id, fingerprint)
moderation_finished.emit(ok, "Player removed." if ok else "Player could not be removed.")
return ok
func ban(peer_id: int, fingerprint: String, name: String, revision: int) -> bool:
if not _valid_moderation_target(peer_id, fingerprint, revision):
moderation_finished.emit(false, "That player is no longer connected.")
return false
var host_fingerprint := _session.get_host_identity_fingerprint()
if not _bans.ban(host_fingerprint, fingerprint, name):
moderation_finished.emit(false, "Ban could not be saved.")
return false
var ok := _session.kick_authenticated_peer(peer_id, fingerprint, true)
moderation_finished.emit(ok, "Player banned." if ok else "Ban saved.")
return true
func unban(fingerprint: String) -> bool:
if not _session.is_host():
return false
return _bans.unban(_session.get_host_identity_fingerprint(), fingerprint)
func is_locally_blocked(fingerprint: String) -> bool:
return _relationships.is_blocked(fingerprint)
func pair_is_blocked(first: String, second: String) -> bool:
if first.is_empty() or second.is_empty():
return false
return _host_block_pairs.has(_pair_key(first, second))
@rpc("any_peer", "call_remote", "reliable", 0)
func submit_block_policy(target_fingerprint: String, blocked: bool) -> void:
var sender := multiplayer.get_remote_sender_id()
if not _session.is_host() or not _session.is_authenticated_peer(sender):
return
var record := _session.get_peer_record(sender)
if (
record == null
or not NetworkIdentityCrypto.valid_fingerprint(target_fingerprint)
or record.identity_fingerprint == target_fingerprint
):
return
_set_host_pair(record.identity_fingerprint, target_fingerprint, blocked)
func _on_relationship_changed(fingerprint: String) -> void:
var blocked := _relationships.is_blocked(fingerprint)
var avatar_peer := _peer_for_fingerprint(fingerprint)
if avatar_peer > 0:
_spawn.set_peer_presentation_visible(avatar_peer, not blocked)
var local_fingerprint := _session.get_local_identity_fingerprint()
if _session.is_host():
_set_host_pair(local_fingerprint, fingerprint, blocked)
elif _session.is_gameplay_session_active():
submit_block_policy.rpc_id(1, fingerprint, blocked)
_chat.refresh_relationship_filters()
_mail.refresh_relationship_filters()
_changed()
func _on_peer_authenticated(peer_id: int, _display_name: String) -> void:
var record := _session.get_peer_record(peer_id)
if record != null:
_peer_fingerprints[peer_id] = record.identity_fingerprint
var blocked := _relationships.is_blocked(record.identity_fingerprint)
_spawn.set_peer_presentation_visible(peer_id, not blocked)
if blocked and peer_id != _session.get_local_peer_id():
var local_fingerprint := _session.get_local_identity_fingerprint()
if _session.is_host():
_set_host_pair(
local_fingerprint, record.identity_fingerprint, true
)
else:
submit_block_policy.rpc_id(
1, record.identity_fingerprint, true
)
_changed()
func _sync_authenticated_peers() -> void:
for peer_id: int in _session.get_authenticated_peer_ids():
if not _peer_fingerprints.has(peer_id):
_on_peer_authenticated(peer_id, "")
_changed()
func _on_peer_removed(peer_id: int) -> void:
var fingerprint := str(_peer_fingerprints.get(peer_id, ""))
_peer_fingerprints.erase(peer_id)
if _session.is_host() and not fingerprint.is_empty():
for key: String in _host_block_pairs.keys():
if fingerprint in key.split(":"):
_host_block_pairs.erase(key)
_changed()
func _on_session_state_changed(state: NetworkSession.State) -> void:
if state in [
NetworkSession.State.INACTIVE,
NetworkSession.State.DISCONNECTING,
NetworkSession.State.CONNECTION_FAILED,
NetworkSession.State.SERVER_LOST,
]:
_host_block_pairs.clear()
_peer_fingerprints.clear()
_changed()
func _set_host_pair(first: String, second: String, blocked: bool) -> void:
var key := _pair_key(first, second)
if blocked:
_host_block_pairs[key] = true
_mail.cancel_unaccepted_between(first, second)
else:
_host_block_pairs.erase(key)
func _pair_key(first: String, second: String) -> String:
return "%s:%s" % ([first, second] if first < second else [second, first])
func _peer_for_fingerprint(fingerprint: String) -> int:
for peer_id: int in _session.get_authenticated_peer_ids():
var record := _session.get_peer_record(peer_id)
if record != null and record.identity_fingerprint == fingerprint:
return peer_id
return 0
func _valid_moderation_target(peer_id: int, fingerprint: String, revision: int) -> bool:
if not _session.is_host() or revision != _revision or peer_id == 1:
return false
var record := _session.get_peer_record(peer_id)
return record != null and record.identity_fingerprint == fingerprint
func _entry_before(a: PlayerListEntry, b: PlayerListEntry) -> bool:
if a.is_host != b.is_host:
return a.is_host
if a.is_local_player != b.is_local_player:
return a.is_local_player
var compared := a.display_name.naturalnocasecmp_to(b.display_name)
return compared < 0 if compared != 0 else a.full_fingerprint < b.full_fingerprint
func _changed() -> void:
_revision += 1
entries_changed.emit()

View file

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

View file

@ -30,6 +30,7 @@ enum RejectionCode {
AUTHENTICATION_TIMEOUT,
SERVER_SHUTTING_DOWN,
UNSUPPORTED_CLIENT,
BANNED,
}
@ -212,5 +213,7 @@ static func rejection_text(code: int) -> String:
return "The server is shutting down."
RejectionCode.UNSUPPORTED_CLIENT:
return "This game build is not supported by the server."
RejectionCode.BANNED:
return "You are not permitted to join this server."
_:
return "The server rejected the connection."

View file

@ -79,6 +79,7 @@ var _player_identity: PlayerIdentityStore
var _host_identity: HostIdentityStore
var _known_players: KnownPlayerStore
var _server_trust: ServerTrustStore
var _host_bans: HostBanStore
var _pending_identity_challenges: Dictionary[int, Dictionary] = {}
var _authenticated_identity_cache: Dictionary[int, Dictionary] = {}
var _client_identity_attempt: Dictionary = {}
@ -89,6 +90,7 @@ var _session_identity_keys: Dictionary[String, String] = {}
var _local_appearance_snapshot: Dictionary = (
CharacterCustomizationCatalog.default_snapshot()
)
var _moderation_disconnect_message := ""
func _ready() -> void:
@ -107,6 +109,7 @@ func setup(
host_identity: HostIdentityStore,
known_players: KnownPlayerStore,
server_trust: ServerTrustStore,
host_bans: HostBanStore,
) -> void:
_profile = profile
_saved_servers = saved_servers
@ -115,6 +118,7 @@ func setup(
_host_identity = host_identity
_known_players = known_players
_server_trust = server_trust
_host_bans = host_bans
if _profile != null:
_profile_ready = _profile.load_or_create()
if _player_identity != null:
@ -293,7 +297,7 @@ func get_player_count() -> int:
func get_session_max_players() -> int:
return session_max_players
return _last_server_max_players if is_joined_client() else session_max_players
func get_current_route_display() -> String:
@ -362,6 +366,50 @@ func get_authenticated_peer_ids() -> Array[int]:
return _registry.get_peer_ids()
func get_peer_rtt_ms(peer_id: int) -> int:
if peer_id == 1:
return 0
var enet := multiplayer.multiplayer_peer as ENetMultiplayerPeer
if (
enet == null
or enet.get_connection_status()
!= MultiplayerPeer.CONNECTION_CONNECTED
):
return -1
var measurable := is_host() or (
is_joined_client() and peer_id == get_local_peer_id()
)
if not measurable:
return -1
if is_joined_client() and not _registry.has_peer(1):
return -1
var packet_peer: ENetPacketPeer = enet.get_peer(peer_id if is_host() else 1)
if packet_peer == null:
return -1
return int(packet_peer.get_statistic(ENetPacketPeer.PEER_ROUND_TRIP_TIME))
func kick_authenticated_peer(
peer_id: int,
fingerprint: String,
banned: bool = false,
) -> bool:
if not is_host() or peer_id <= 1:
return false
var record := _registry.get_peer(peer_id)
if record == null or record.identity_fingerprint != fingerprint:
return false
receive_moderation_disconnect.rpc_id(
peer_id,
(
"You are not permitted to join this server."
if banned else "You were removed by the host."
),
)
call_deferred("_disconnect_rejected_peer", peer_id)
return true
func get_local_identity_fingerprint() -> String:
return _player_identity.fingerprint if _player_identity != null else ""
@ -601,9 +649,15 @@ func _on_server_disconnected() -> void:
]:
_fail("The server rejected or ended authentication.")
return
_set_state(State.SERVER_LOST, "The server connection was lost.")
var message := (
_moderation_disconnect_message
if not _moderation_disconnect_message.is_empty()
else "The server connection was lost."
)
_moderation_disconnect_message = ""
_set_state(State.SERVER_LOST, message)
server_lost.emit()
connection_error.emit("The server connection was lost.")
connection_error.emit(message)
func _on_peer_disconnected(peer_id: int) -> void:
@ -782,9 +836,24 @@ func submit_client_identity_proof(data: Dictionary) -> void:
"fingerprint": challenge["client_fingerprint"],
"public_key": challenge["client_public_key"],
}
if (
_host_bans != null
and _host_bans.is_banned(
_host_identity.fingerprint,
str(challenge["client_fingerprint"]),
)
):
_reject_peer(sender_id, NetworkProtocol.RejectionCode.BANNED)
return
request_client_profile.rpc_id(sender_id)
@rpc("authority", "call_remote", "reliable", 0)
func receive_moderation_disconnect(message: String) -> void:
_moderation_disconnect_message = message.left(120)
connection_error.emit(_moderation_disconnect_message)
@rpc("authority", "call_remote", "reliable", 0)
func request_client_profile() -> void:
if state != State.AUTHENTICATING:
@ -913,11 +982,13 @@ func _reject_peer(
func _disconnect_rejected_peer(peer_id: int) -> void:
await get_tree().create_timer(0.15).timeout
if (
is_host()
and multiplayer.multiplayer_peer != null
and multiplayer.multiplayer_peer.get_connection_status()
== MultiplayerPeer.CONNECTION_CONNECTED
and peer_id in multiplayer.get_peers()
):
multiplayer.multiplayer_peer.disconnect_peer(peer_id)

View file

@ -0,0 +1,16 @@
class_name PlayerListEntry
extends RefCounted
var peer_id := 0
var full_fingerprint := ""
var compact_fingerprint := ""
var display_name := ""
var is_host := false
var is_local_player := false
var continuity_state := ""
var ping_to_host_ms := -1
var muted := false
var blocked := false
var can_kick := false
var can_ban := false
var revision := 0

View file

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

View file

@ -0,0 +1,143 @@
class_name PlayerRelationshipStore
extends Node
signal relationship_changed(fingerprint: String)
const FORMAT_VERSION := 1
const STORE_PATH := "user://player_relationships.json"
const TEMP_PATH := STORE_PATH + ".tmp"
const MAX_RECORDS := 500
var _records: Dictionary = {}
var _loaded := false
var _write_blocked := false
func is_muted(fingerprint: String) -> bool:
_ensure_loaded()
return bool(_records.get(fingerprint, {}).get("muted", false))
func is_blocked(fingerprint: String) -> bool:
_ensure_loaded()
return bool(_records.get(fingerprint, {}).get("blocked", false))
func set_muted(fingerprint: String, display_name: String, value: bool) -> bool:
if not _valid_target(fingerprint, display_name):
return false
_ensure_loaded()
var record := _record(fingerprint, display_name)
record["muted"] = value or bool(record.get("blocked", false))
return _commit(fingerprint, record)
func set_blocked(fingerprint: String, display_name: String, value: bool) -> bool:
if not _valid_target(fingerprint, display_name):
return false
_ensure_loaded()
var record := _record(fingerprint, display_name)
record["blocked"] = value
if value:
record["muted"] = true
return _commit(fingerprint, record)
func get_records() -> Array[Dictionary]:
_ensure_loaded()
var result: Array[Dictionary] = []
for value: Dictionary in _records.values():
if bool(value.get("muted", false)) or bool(value.get("blocked", false)):
result.append(value.duplicate(true))
result.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return str(a.get("last_known_display_name", "")).naturalnocasecmp_to(
str(b.get("last_known_display_name", ""))
) < 0
)
return result
func _record(fingerprint: String, display_name: String) -> Dictionary:
var now := int(Time.get_unix_time_from_system())
var record: Dictionary = _records.get(fingerprint, {
"fingerprint": fingerprint,
"created_unix": now,
"muted": false,
"blocked": false,
})
record["last_known_display_name"] = display_name
record["updated_unix"] = now
return record
func _commit(fingerprint: String, record: Dictionary) -> bool:
if _write_blocked:
return false
var previous: Dictionary = _records.duplicate(true)
if not bool(record["muted"]) and not bool(record["blocked"]):
_records.erase(fingerprint)
else:
_records[fingerprint] = record
while _records.size() > MAX_RECORDS:
_records.erase(_records.keys().front())
if not _save():
_records = previous
return false
relationship_changed.emit(fingerprint)
return true
func _valid_target(fingerprint: String, display_name: String) -> bool:
return (
NetworkIdentityCrypto.valid_fingerprint(fingerprint)
and NetworkProfilePreferences.is_valid_display_name(display_name)
)
func _ensure_loaded() -> void:
if _loaded:
return
_loaded = true
if not FileAccess.file_exists(STORE_PATH):
return
var file := FileAccess.open(STORE_PATH, FileAccess.READ)
if file == null:
return
var json := JSON.new()
if json.parse(file.get_as_text()) != OK or typeof(json.data) != TYPE_DICTIONARY:
return
var data: Dictionary = json.data
if data.get("format_version") != FORMAT_VERSION:
_write_blocked = true
return
for value: Variant in data.get("records", []):
if typeof(value) != TYPE_DICTIONARY:
continue
var record: Dictionary = value
var fingerprint := str(record.get("fingerprint", ""))
if not NetworkIdentityCrypto.valid_fingerprint(fingerprint):
continue
record["blocked"] = bool(record.get("blocked", false))
record["muted"] = bool(record.get("muted", false)) or record["blocked"]
_records[fingerprint] = record.duplicate(true)
func _save() -> bool:
var file := FileAccess.open(TEMP_PATH, FileAccess.WRITE)
if file == null:
return false
file.store_string(JSON.stringify({
"format_version": FORMAT_VERSION,
"records": _records.values(),
}, "\t"))
file.flush()
var ok := file.get_error() == OK
file.close()
if not ok:
return false
if FileAccess.file_exists(STORE_PATH):
DirAccess.remove_absolute(ProjectSettings.globalize_path(STORE_PATH))
return DirAccess.rename_absolute(
ProjectSettings.globalize_path(TEMP_PATH),
ProjectSettings.globalize_path(STORE_PATH),
) == OK

View file

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

View file

@ -78,6 +78,12 @@ func get_avatar(peer_id: int) -> Player:
return _avatars.get(peer_id)
func set_peer_presentation_visible(peer_id: int, visible: bool) -> void:
var avatar: Player = _avatars.get(peer_id)
if avatar != null and is_instance_valid(avatar):
avatar.set_remote_presentation_visible(visible)
func get_peer_ids() -> Array[int]:
var result: Array[int] = []
for peer_id: int in _avatars: