diff --git a/fishing/remote_fishing_presentation.gd b/fishing/remote_fishing_presentation.gd index 3cbb1f2..98e5ea8 100644 --- a/fishing/remote_fishing_presentation.gd +++ b/fishing/remote_fishing_presentation.gd @@ -70,6 +70,10 @@ func cleanup() -> void: func _process(_delta: float) -> void: + if _owner != null and not _owner.is_remote_presentation_visible(): + _bobber.visible = false + _line.visible = false + return if _active: _redraw_line() diff --git a/main/main.gd b/main/main.gd index a525d3c..70517ec 100644 --- a/main/main.gd +++ b/main/main.gd @@ -104,6 +104,8 @@ const TITLE_MUSIC_SILENCE_DB: float = -80.0 @onready var _host_identity: HostIdentityStore = %HostIdentityStore @onready var _known_players: KnownPlayerStore = %KnownPlayerStore @onready var _server_trust: ServerTrustStore = %ServerTrustStore +@onready var _relationships: PlayerRelationshipStore = %PlayerRelationshipStore +@onready var _host_bans: HostBanStore = %HostBanStore @onready var _saved_servers: SavedServerStoreType = %SavedServerStore @onready var _player_spawn_service: PlayerSpawnServiceType = ( %PlayerSpawnService @@ -118,6 +120,7 @@ const TITLE_MUSIC_SILENCE_DB: float = -80.0 ) @onready var _network_chat: NetworkChatServiceType = %NetworkChatService @onready var _network_mail: NetworkMailServiceType = %NetworkMailService +@onready var _network_player_list: NetworkPlayerListService = %NetworkPlayerListService @onready var _asset_reservations: PlayerAssetReservationServiceType = ( %PlayerAssetReservationService ) @@ -157,6 +160,7 @@ func _ready() -> void: _host_identity, _known_players, _server_trust, + _host_bans, ) _network_profile_service.setup( _network_session, @@ -164,6 +168,15 @@ func _ready() -> void: _appearance_store, _player_spawn_service, ) + _network_player_list.setup( + _network_session, + _relationships, + _host_bans, + _known_players, + _player_spawn_service, + _network_chat, + _network_mail, + ) _network_session.set_local_appearance_snapshot( _appearance_store.get_snapshot() ) @@ -310,7 +323,8 @@ func _ready() -> void: _player_spawn_service, _network_mail, _asset_reservations, - _network_profile_service + _network_profile_service, + _network_player_list, ) _water_recovery.setup( _player, diff --git a/main/main.tscn b/main/main.tscn index 0645ab7..df5662a 100644 --- a/main/main.tscn +++ b/main/main.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=34 format=3] +[gd_scene load_steps=37 format=3] [ext_resource type="PackedScene" path="res://world/test_world.tscn" id="1_world"] [ext_resource type="PackedScene" path="res://player/player.tscn" id="2_player"] @@ -33,6 +33,9 @@ [ext_resource type="Script" path="res://network/host_identity_store.gd" id="31_host_identity"] [ext_resource type="Script" path="res://network/known_player_store.gd" id="32_known_players"] [ext_resource type="Script" path="res://network/server_trust_store.gd" id="33_server_trust"] +[ext_resource type="Script" path="res://network/player_relationship_store.gd" id="34_relationships"] +[ext_resource type="Script" path="res://network/host_ban_store.gd" id="35_bans"] +[ext_resource type="Script" path="res://network/network_player_list_service.gd" id="36_player_list"] [node name="Main" type="Node3D"] script = ExtResource("3_main") @@ -65,6 +68,14 @@ script = ExtResource("32_known_players") unique_name_in_owner = true script = ExtResource("33_server_trust") +[node name="PlayerRelationshipStore" type="Node" parent="."] +unique_name_in_owner = true +script = ExtResource("34_relationships") + +[node name="HostBanStore" type="Node" parent="."] +unique_name_in_owner = true +script = ExtResource("35_bans") + [node name="PlayerAppearanceStore" type="Node" parent="."] unique_name_in_owner = true script = ExtResource("28_appearance") @@ -109,6 +120,10 @@ script = ExtResource("27_reservations") unique_name_in_owner = true script = ExtResource("26_network_mail") +[node name="NetworkPlayerListService" type="Node" parent="."] +unique_name_in_owner = true +script = ExtResource("36_player_list") + [node name="TestWorld" parent="." instance=ExtResource("1_world")] [node name="Players" type="Node3D" parent="."] diff --git a/network/host_ban_store.gd b/network/host_ban_store.gd new file mode 100644 index 0000000..4a8d937 --- /dev/null +++ b/network/host_ban_store.gd @@ -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 diff --git a/network/host_ban_store.gd.uid b/network/host_ban_store.gd.uid new file mode 100644 index 0000000..54c03fc --- /dev/null +++ b/network/host_ban_store.gd.uid @@ -0,0 +1 @@ +uid://c71hostbans000001 diff --git a/network/network_chat_service.gd b/network/network_chat_service.gd index 6c00e2f..fcf3ff7 100644 --- a/network/network_chat_service.gd +++ b/network/network_chat_service.gd @@ -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: diff --git a/network/network_mail_service.gd b/network/network_mail_service.gd index e248a4c..f4f3456 100644 --- a/network/network_mail_service.gd +++ b/network/network_mail_service.gd @@ -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) diff --git a/network/network_player_list_service.gd b/network/network_player_list_service.gd new file mode 100644 index 0000000..2681a14 --- /dev/null +++ b/network/network_player_list_service.gd @@ -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() diff --git a/network/network_player_list_service.gd.uid b/network/network_player_list_service.gd.uid new file mode 100644 index 0000000..be485b6 --- /dev/null +++ b/network/network_player_list_service.gd.uid @@ -0,0 +1 @@ +uid://c71playerlist0001 diff --git a/network/network_protocol.gd b/network/network_protocol.gd index 0df56aa..a8672f5 100644 --- a/network/network_protocol.gd +++ b/network/network_protocol.gd @@ -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." diff --git a/network/network_session.gd b/network/network_session.gd index 0d67c06..2afff76 100644 --- a/network/network_session.gd +++ b/network/network_session.gd @@ -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) diff --git a/network/player_list_entry.gd b/network/player_list_entry.gd new file mode 100644 index 0000000..fbe0d8f --- /dev/null +++ b/network/player_list_entry.gd @@ -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 diff --git a/network/player_list_entry.gd.uid b/network/player_list_entry.gd.uid new file mode 100644 index 0000000..fa479f9 --- /dev/null +++ b/network/player_list_entry.gd.uid @@ -0,0 +1 @@ +uid://c71playerentry001 diff --git a/network/player_relationship_store.gd b/network/player_relationship_store.gd new file mode 100644 index 0000000..ab1d53a --- /dev/null +++ b/network/player_relationship_store.gd @@ -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 diff --git a/network/player_relationship_store.gd.uid b/network/player_relationship_store.gd.uid new file mode 100644 index 0000000..a63c807 --- /dev/null +++ b/network/player_relationship_store.gd.uid @@ -0,0 +1 @@ +uid://c71relationships01 diff --git a/network/player_spawn_service.gd b/network/player_spawn_service.gd index bf62dd0..c31feb6 100644 --- a/network/player_spawn_service.gd +++ b/network/player_spawn_service.gd @@ -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: diff --git a/player/player.gd b/player/player.gd index eb7dad3..36a0ae9 100644 --- a/player/player.gd +++ b/player/player.gd @@ -108,6 +108,7 @@ var _movement_enabled: bool = true var _water_recovery_active: bool = false var _target_zoom: float = 5.0 var _showcase_rod_visibility: bool = true +var _remote_presentation_visible := true var _showcase_rod_state_stored: bool = false var _showcase_visual_rotation: Vector3 var _showcase_visual_rotation_stored: bool = false @@ -551,6 +552,17 @@ func get_gameplay_camera() -> Camera3D: return _camera +func set_remote_presentation_visible(value: bool) -> void: + if local_control_enabled: + return + _remote_presentation_visible = value + _visuals.visible = value + + +func is_remote_presentation_visible() -> bool: + return _remote_presentation_visible + + func get_fishing_rod_tip() -> Marker3D: return _fishing_rod_tip diff --git a/ui/chat_ui.gd b/ui/chat_ui.gd index 3926f13..d9bed6b 100644 --- a/ui/chat_ui.gd +++ b/ui/chat_ui.gd @@ -187,12 +187,19 @@ func _on_message(message: Dictionary) -> void: _speech[peer_id] = { "label": label, "expires": Time.get_ticks_msec() / 1000.0 + SPEECH_SECONDS, + "fingerprint": str(message.get("sender_fingerprint", "")), } func _on_history(_messages: Array) -> void: - if _messages.is_empty(): - for peer_id: int in _speech.keys(): + for peer_id: int in _speech.keys(): + var state: Dictionary = _speech[peer_id] + if ( + _messages.is_empty() + or _service.is_sender_filtered( + str(state.get("fingerprint", "")) + ) + ): _on_peer_removed(peer_id) _refresh_history() diff --git a/ui/game_ui.gd b/ui/game_ui.gd index e76beff..c3e8e57 100644 --- a/ui/game_ui.gd +++ b/ui/game_ui.gd @@ -119,6 +119,7 @@ func setup( network_mail_service: NetworkMailService, reservations: PlayerAssetReservationService, network_profile_service: NetworkProfileService, + network_player_list: NetworkPlayerListService, ) -> void: _fishing_spot = fishing_spot _item_effects = item_effects @@ -155,7 +156,8 @@ func setup( network_sale_service, network_mail_service, reservations, - network_profile_service + network_profile_service, + network_player_list, ) _hotbar_ui.setup(hotbar, bag, item_catalog, fishing_spot) _fishing_shop.setup( diff --git a/ui/player_menu.gd b/ui/player_menu.gd index 53b3e5a..943ac39 100644 --- a/ui/player_menu.gd +++ b/ui/player_menu.gd @@ -88,6 +88,7 @@ enum Section { LOGBOOK, MAIL, PROFILE, + PLAYERS, } enum SortMode { @@ -158,6 +159,7 @@ enum CloseReason { @onready var _logbook_page: Control = %LogbookPage @onready var _mail_page: MailPage = %MailPage @onready var _profile_page: ProfilePage = %ProfilePage +@onready var _players_page: PlayersPage = %PlayersPage @onready var _book_backing: PanelContainer = %BookBacking @onready var _book_spread: BoxContainer = %BookSpread @onready var _left_page: PanelContainer = %LeftPage @@ -176,6 +178,7 @@ enum CloseReason { @onready var _logbook_tab: BubbleButtonType = %LogbookTab @onready var _mail_tab: BubbleButtonType = %MailTab @onready var _profile_tab: BubbleButtonType = %ProfileTab +@onready var _players_tab: BubbleButtonType = %PlayersTab @onready var _mail_unread_badge: Label = %MailUnreadBadge @onready var _close_button: BubbleButtonType = %CloseButton @onready var _content_shell: BubbleContentShellType = %MenuPanel @@ -237,6 +240,7 @@ var _network_session: NetworkSessionType var _network_sale_service: NetworkSaleService var _network_mail_service: NetworkMailService var _network_profile_service: NetworkProfileService +var _network_player_list: NetworkPlayerListService var _default_buyer: FishBuyerProfileType var _catalog: FishPoolType var _fishing_spot: FishingSpotType @@ -300,6 +304,7 @@ func _ready() -> void: ) _mail_tab.pressed.connect(_show_section.bind(Section.MAIL)) _profile_tab.pressed.connect(_show_section.bind(Section.PROFILE)) + _players_tab.pressed.connect(_show_section.bind(Section.PLAYERS)) _close_button.pressed.connect(close_menu) _sort_option.item_selected.connect(_on_sort_selected.bind(_sort_option)) _sort_direction.pressed.connect(_on_sort_direction_pressed) @@ -327,6 +332,7 @@ func _ready() -> void: _logbook_tab, _mail_tab, _profile_tab, + _players_tab, _close_button, ]) _configure_navigation_focus() @@ -376,6 +382,7 @@ func setup( network_mail_service: NetworkMailService, reservations: PlayerAssetReservationService, network_profile_service: NetworkProfileService, + network_player_list: NetworkPlayerListService, ) -> void: _player = player _inventory = inventory @@ -393,10 +400,12 @@ func setup( _network_sale_service = network_sale_service _network_mail_service = network_mail_service _network_profile_service = network_profile_service + _network_player_list = network_player_list _mail_page.setup( network_mail_service, reservations, inventory, wallet, bag, item_catalog ) _profile_page.setup(network_profile_service) + _players_page.setup(network_player_list) _network_mail_service.unread_count_changed.connect( _on_mail_unread_count_changed ) @@ -608,6 +617,7 @@ func _show_section_immediate(section: Section) -> void: _logbook_page.visible = section == Section.LOGBOOK _mail_page.visible = section == Section.MAIL _profile_page.visible = section == Section.PROFILE + _players_page.visible = section == Section.PLAYERS _content_shell.visible = false _inventory_section.visible = false _bag_section.visible = false @@ -634,11 +644,16 @@ func _show_section_immediate(section: Section) -> void: _profile_page.activate() else: _profile_page.deactivate() + if section == Section.PLAYERS: + _players_page.activate() + else: + _players_page.deactivate() _inventory_tab.button_pressed = section == Section.COOLER _bag_tab.button_pressed = section == Section.BAG _logbook_tab.button_pressed = section == Section.LOGBOOK _mail_tab.button_pressed = section == Section.MAIL _profile_tab.button_pressed = section == Section.PROFILE + _players_tab.button_pressed = section == Section.PLAYERS _update_navigation_selection() _configure_active_page_focus() @@ -652,6 +667,8 @@ func _focus_current_section() -> void: _logbook_tab.grab_focus() elif _current_section == Section.MAIL: _mail_tab.grab_focus() + elif _current_section == Section.PLAYERS: + _players_tab.grab_focus() else: _profile_tab.grab_focus() @@ -682,6 +699,7 @@ func _configure_navigation_focus() -> void: _logbook_tab, _mail_tab, _profile_tab, + _players_tab, _close_button, ] for index: int in navigation.size(): @@ -835,6 +853,7 @@ func _apply_navigation_styles() -> void: _logbook_tab, _mail_tab, _profile_tab, + _players_tab, _close_button, ]: bubble.add_theme_stylebox_override("normal", normal) @@ -866,6 +885,7 @@ func _apply_navigation_selection_presentation() -> void: _logbook_tab, _mail_tab, _profile_tab, + _players_tab, ]: if not bubble.button_pressed: continue @@ -975,6 +995,7 @@ func _set_navigation_target(section: Section) -> void: _logbook_tab.button_pressed = section == Section.LOGBOOK _mail_tab.button_pressed = section == Section.MAIL _profile_tab.button_pressed = section == Section.PROFILE + _players_tab.button_pressed = section == Section.PLAYERS func _update_shell_layout() -> void: @@ -1591,6 +1612,7 @@ func _set_shell_interactive(interactive: bool) -> void: _logbook_tab, _mail_tab, _profile_tab, + _players_tab, _close_button, ]: bubble.focus_mode = Control.FOCUS_ALL if interactive else Control.FOCUS_NONE diff --git a/ui/player_menu.tscn b/ui/player_menu.tscn index cfb2a49..4ea9b6a 100644 --- a/ui/player_menu.tscn +++ b/ui/player_menu.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=21 format=3] +[gd_scene load_steps=22 format=3] [ext_resource type="Script" path="res://ui/player_menu.gd" id="1_menu"] [ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"] @@ -19,6 +19,7 @@ [ext_resource type="Texture2D" path="res://ui/assets/title/bubbles/bubble3.png" id="17_bubble3"] [ext_resource type="Script" path="res://ui/mail_page.gd" id="18_mail_page"] [ext_resource type="PackedScene" path="res://ui/profile_page.tscn" id="19_profile_page"] +[ext_resource type="Script" path="res://ui/players_page.gd" id="20_players_page"] [sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_collection"] @@ -592,6 +593,15 @@ offset_top = 112.0 offset_right = 1280.0 offset_bottom = 710.0 +[node name="PlayersPage" type="Control" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot"] +visible = false +unique_name_in_owner = true +layout_mode = 0 +offset_right = 1280.0 +offset_bottom = 720.0 +mouse_filter = 1 +script = ExtResource("20_players_page") + [node name="BookBacking" type="PanelContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/LogbookPage"] unique_name_in_owner = true layout_mode = 0 @@ -1232,9 +1242,9 @@ toggle_mode = true text = "cooler" script = ExtResource("6_bubble") profile = ExtResource("4_profile") -neutral_size = Vector2(124, 120) -desktop_anchor = Vector2(92, 60) -compact_anchor = Vector2(92, 73.3333) +neutral_size = Vector2(112, 108) +desktop_anchor = Vector2(70, 60) +compact_anchor = Vector2(70, 73.3333) compact_minimum_size = Vector2(84, 82) minimum_font_size = 25 maximum_font_size = 25 @@ -1252,9 +1262,9 @@ toggle_mode = true text = "bag" script = ExtResource("6_bubble") profile = ExtResource("4_profile") -neutral_size = Vector2(124, 120) -desktop_anchor = Vector2(220, 60) -compact_anchor = Vector2(220, 73.3333) +neutral_size = Vector2(112, 108) +desktop_anchor = Vector2(182, 60) +compact_anchor = Vector2(182, 73.3333) compact_minimum_size = Vector2(84, 82) minimum_font_size = 25 maximum_font_size = 25 @@ -1272,9 +1282,9 @@ toggle_mode = true text = "logbook" script = ExtResource("6_bubble") profile = ExtResource("4_profile") -neutral_size = Vector2(124, 120) -desktop_anchor = Vector2(348, 60) -compact_anchor = Vector2(348, 73.3333) +neutral_size = Vector2(112, 108) +desktop_anchor = Vector2(294, 60) +compact_anchor = Vector2(294, 73.3333) compact_minimum_size = Vector2(84, 82) minimum_font_size = 25 maximum_font_size = 25 @@ -1292,9 +1302,9 @@ toggle_mode = true text = "mail" script = ExtResource("6_bubble") profile = ExtResource("4_profile") -neutral_size = Vector2(124, 120) -desktop_anchor = Vector2(476, 60) -compact_anchor = Vector2(476, 73.3333) +neutral_size = Vector2(112, 108) +desktop_anchor = Vector2(406, 60) +compact_anchor = Vector2(406, 73.3333) compact_minimum_size = Vector2(84, 82) minimum_font_size = 25 maximum_font_size = 25 @@ -1328,9 +1338,9 @@ toggle_mode = true text = "profile" script = ExtResource("6_bubble") profile = ExtResource("4_profile") -neutral_size = Vector2(124, 120) -desktop_anchor = Vector2(604, 60) -compact_anchor = Vector2(604, 73.3333) +neutral_size = Vector2(112, 108) +desktop_anchor = Vector2(518, 60) +compact_anchor = Vector2(518, 73.3333) compact_minimum_size = Vector2(84, 82) minimum_font_size = 23 maximum_font_size = 25 @@ -1341,6 +1351,26 @@ motion_phase = 4.05 deformation_amplitude = 0.011 deformation_period = 6.5 +[node name="PlayersTab" type="Button" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/NavigationCluster"] +unique_name_in_owner = true +layout_mode = 0 +toggle_mode = true +text = "players" +script = ExtResource("6_bubble") +profile = ExtResource("4_profile") +neutral_size = Vector2(112, 108) +desktop_anchor = Vector2(630, 60) +compact_anchor = Vector2(630, 73.3333) +compact_minimum_size = Vector2(84, 82) +minimum_font_size = 22 +maximum_font_size = 24 +horizontal_amplitude = 1.1 +vertical_amplitude = 2.2 +motion_period = 5.8 +motion_phase = 4.35 +deformation_amplitude = 0.011 +deformation_period = 6.4 + [node name="CloseButton" type="Button" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/NavigationCluster"] unique_name_in_owner = true layout_mode = 0 @@ -1348,8 +1378,8 @@ text = "close" script = ExtResource("6_bubble") profile = ExtResource("4_profile") neutral_size = Vector2(96, 94) -desktop_anchor = Vector2(744, 57) -compact_anchor = Vector2(744, 73.3333) +desktop_anchor = Vector2(770, 57) +compact_anchor = Vector2(770, 73.3333) compact_minimum_size = Vector2(76, 74) minimum_font_size = 23 maximum_font_size = 23 diff --git a/ui/players_page.gd b/ui/players_page.gd new file mode 100644 index 0000000..e32bf5a --- /dev/null +++ b/ui/players_page.gd @@ -0,0 +1,289 @@ +class_name PlayersPage +extends Control + +var _service: NetworkPlayerListService +var _header: Label +var _tabs: HBoxContainer +var _list: VBoxContainer +var _status: Label +var _current_tab := 0 + + +func _ready() -> void: + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + _build() + + +func setup(service: NetworkPlayerListService) -> void: + _service = service + _service.entries_changed.connect(_refresh) + _service.moderation_finished.connect(func(_ok: bool, message: String) -> void: + _status.text = message + _refresh() + ) + _refresh() + + +func activate() -> void: + _refresh() + _focus_first() + + +func deactivate() -> void: + _status.text = "" + + +func _build() -> void: + var paper := PanelContainer.new() + paper.position = Vector2(58, 128) + paper.size = Vector2(1164, 476) + add_child(paper) + var style := StyleBoxFlat.new() + style.bg_color = Color("eee2bd") + style.border_color = Color("473d2e") + style.set_border_width_all(4) + style.set_corner_radius_all(16) + style.content_margin_left = 26 + style.content_margin_right = 26 + style.content_margin_top = 20 + style.content_margin_bottom = 20 + paper.add_theme_stylebox_override("panel", style) + var root := VBoxContainer.new() + root.add_theme_constant_override("separation", 10) + paper.add_child(root) + _header = Label.new() + _header.add_theme_font_size_override("font_size", 27) + _header.add_theme_color_override("font_color", Color("28251f")) + root.add_child(_header) + _tabs = HBoxContainer.new() + _tabs.add_theme_constant_override("separation", 10) + root.add_child(_tabs) + for index: int in 3: + var button := Button.new() + button.text = ["players", "relationships", "banned"][index] + button.toggle_mode = true + button.pressed.connect(_select_tab.bind(index)) + _tabs.add_child(button) + var scroll := ScrollContainer.new() + scroll.custom_minimum_size = Vector2(0, 330) + scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + root.add_child(scroll) + _list = VBoxContainer.new() + _list.custom_minimum_size = Vector2(1080, 0) + _list.add_theme_constant_override("separation", 7) + scroll.add_child(_list) + _status = Label.new() + _status.add_theme_color_override("font_color", Color("514535")) + root.add_child(_status) + + +func _select_tab(index: int) -> void: + if index == 2 and (_service == null or not _service.is_local_host()): + return + _current_tab = index + _refresh() + _focus_first() + + +func _refresh() -> void: + if _service == null or _list == null: + return + _header.text = "players %d / %d connected" % [ + _service.get_connected_count(), _service.get_max_players(), + ] + for index: int in _tabs.get_child_count(): + var button := _tabs.get_child(index) as Button + button.button_pressed = index == _current_tab + button.visible = index != 2 or _service.is_local_host() + for child: Node in _list.get_children(): + child.queue_free() + match _current_tab: + 0: + _build_active_rows() + 1: + _build_relationship_rows() + 2: + _build_ban_rows() + + +func _build_active_rows() -> void: + var entries := _service.get_entries() + if entries.is_empty(): + _add_empty("No authenticated players.") + return + for entry: PlayerListEntry in entries: + var row := _make_row() + var identity := Label.new() + identity.custom_minimum_size.x = 515 + var markers: Array[String] = [] + if entry.is_host: + markers.append("host") + if entry.is_local_player: + markers.append("You") + identity.text = "%s%s · %s\n%s" % [ + entry.display_name, + " [%s]" % ", ".join(markers) if not markers.is_empty() else "", + entry.compact_fingerprint, + entry.continuity_state, + ] + identity.tooltip_text = NetworkIdentityCrypto.format_fingerprint( + entry.full_fingerprint + ) + row.add_child(identity) + var ping := Label.new() + ping.custom_minimum_size.x = 80 + ping.text = ( + "Local" if entry.is_host + else "%d ms" % entry.ping_to_host_ms + if entry.ping_to_host_ms >= 0 else "—" + ) + row.add_child(ping) + var mute := Button.new() + mute.text = "unmute" if entry.muted else "mute" + mute.disabled = entry.is_local_player + mute.pressed.connect(_toggle_mute.bind(entry)) + row.add_child(mute) + var block := Button.new() + block.text = "block" + block.disabled = entry.is_local_player + block.pressed.connect(_confirm_block.bind(entry)) + row.add_child(block) + var kick := Button.new() + kick.text = "kick" + kick.disabled = not entry.can_kick + kick.pressed.connect(_confirm_kick.bind(entry)) + row.add_child(kick) + var ban := Button.new() + ban.text = "ban" + ban.disabled = not entry.can_ban + ban.pressed.connect(_confirm_ban.bind(entry)) + row.add_child(ban) + _list.add_child(row) + + +func _build_relationship_rows() -> void: + var records := _service.get_relationships() + if records.is_empty(): + _add_empty("No muted or blocked identities.") + return + for record: Dictionary in records: + var row := _make_row() + var label := Label.new() + label.custom_minimum_size.x = 690 + var fingerprint := str(record["fingerprint"]) + label.text = "%s · %s %s" % [ + str(record.get("last_known_display_name", "Player")), + NetworkIdentityCrypto.compact_suffix(fingerprint), + "Blocked" if bool(record.get("blocked", false)) else "Muted", + ] + label.tooltip_text = NetworkIdentityCrypto.format_fingerprint(fingerprint) + row.add_child(label) + if bool(record.get("blocked", false)): + var unblock := Button.new() + unblock.text = "unblock" + unblock.pressed.connect(func() -> void: + _service.set_blocked(fingerprint, str(record["last_known_display_name"]), false) + ) + row.add_child(unblock) + var unmute := Button.new() + unmute.text = "unmute" + unmute.disabled = bool(record.get("blocked", false)) + unmute.pressed.connect(func() -> void: + _service.set_muted(fingerprint, str(record["last_known_display_name"]), false) + ) + row.add_child(unmute) + _list.add_child(row) + + +func _build_ban_rows() -> void: + var records := _service.get_bans() + if records.is_empty(): + _add_empty("No banned identities.") + return + for record: Dictionary in records: + var row := _make_row() + var fingerprint := str(record["target_fingerprint"]) + var label := Label.new() + label.custom_minimum_size.x = 830 + label.text = "%s · %s banned %s" % [ + str(record.get("last_known_display_name", "Player")), + NetworkIdentityCrypto.compact_suffix(fingerprint), + Time.get_date_string_from_unix_time(int(record.get("banned_unix", 0))), + ] + label.tooltip_text = NetworkIdentityCrypto.format_fingerprint(fingerprint) + row.add_child(label) + var unban := Button.new() + unban.text = "unban" + unban.pressed.connect(_confirm_unban.bind(fingerprint)) + row.add_child(unban) + _list.add_child(row) + + +func _make_row() -> HBoxContainer: + var row := HBoxContainer.new() + row.custom_minimum_size.y = 58 + row.add_theme_constant_override("separation", 8) + return row + + +func _add_empty(text: String) -> void: + var label := Label.new() + label.text = text + label.custom_minimum_size.y = 100 + label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + _list.add_child(label) + + +func _toggle_mute(entry: PlayerListEntry) -> void: + _service.set_muted(entry.full_fingerprint, entry.display_name, not entry.muted) + + +func _confirm_block(entry: PlayerListEntry) -> void: + _confirm( + "Block %s?\nTheir local social and visual presentation will be hidden." + % entry.display_name, + func() -> void: + _service.set_blocked(entry.full_fingerprint, entry.display_name, true) + ) + + +func _confirm_kick(entry: PlayerListEntry) -> void: + _confirm("Remove %s from this session?" % entry.display_name, func() -> void: + _service.kick(entry.peer_id, entry.full_fingerprint, entry.revision) + ) + + +func _confirm_ban(entry: PlayerListEntry) -> void: + _confirm("Ban %s · %s from this server?" % [ + entry.display_name, entry.compact_fingerprint, + ], func() -> void: + _service.ban( + entry.peer_id, entry.full_fingerprint, entry.display_name, entry.revision + ) + ) + + +func _confirm_unban(fingerprint: String) -> void: + _confirm("Unban %s?" % NetworkIdentityCrypto.compact_suffix(fingerprint), func() -> void: + _service.unban(fingerprint) + ) + + +func _confirm(text: String, action: Callable) -> void: + var dialog := ConfirmationDialog.new() + dialog.dialog_text = text + dialog.ok_button_text = "confirm" + dialog.canceled.connect(dialog.queue_free) + dialog.confirmed.connect(func() -> void: + action.call() + dialog.queue_free() + ) + add_child(dialog) + dialog.popup_centered(Vector2i(520, 220)) + + +func _focus_first() -> void: + for child: Node in _tabs.get_children(): + if child is Button and child.visible and not child.disabled: + child.grab_focus() + return diff --git a/ui/players_page.gd.uid b/ui/players_page.gd.uid new file mode 100644 index 0000000..e93601d --- /dev/null +++ b/ui/players_page.gd.uid @@ -0,0 +1 @@ +uid://c71playerspage0001