diff --git a/main/main.gd b/main/main.gd index 4167238..3671356 100644 --- a/main/main.gd +++ b/main/main.gd @@ -58,6 +58,12 @@ const NetworkItemUseServiceType = preload( const NetworkChatServiceType = preload( "res://network/network_chat_service.gd" ) +const NetworkMailServiceType = preload( + "res://network/network_mail_service.gd" +) +const PlayerAssetReservationServiceType = preload( + "res://progression/player_asset_reservation_service.gd" +) const TITLE_MUSIC_SILENCE_DB: float = -80.0 @@ -101,6 +107,10 @@ const TITLE_MUSIC_SILENCE_DB: float = -80.0 %NetworkItemUseService ) @onready var _network_chat: NetworkChatServiceType = %NetworkChatService +@onready var _network_mail: NetworkMailServiceType = %NetworkMailService +@onready var _asset_reservations: PlayerAssetReservationServiceType = ( + %PlayerAssetReservationService +) @onready var _players_root: Node3D = $Players var _gameplay_started: bool = false @@ -160,13 +170,29 @@ func _ready() -> void: _player.cooler_capacity ) _save_manager.set_autosave_enabled(false) + _asset_reservations.setup( + _player.wallet, _player.inventory, _player.bag, item_catalog + ) + _network_mail.setup( + _network_session, + _asset_reservations, + _player.wallet, + _player.inventory, + _player.bag, + _player.collection_log, + _player.cooler_capacity, + item_catalog, + fish_catalog, + _save_manager + ) _network_item_use.setup( _network_session, _player_spawn_service, item_catalog, _player.bag, _player.item_effects, - _save_manager + _save_manager, + _asset_reservations ) _network_chat.setup(_network_session) _network_fishing.setup( @@ -191,7 +217,8 @@ func _ready() -> void: _save_manager, fish_catalog, pelican_buyer_profile, - _test_world.get_pelican_convenience_landmark() + _test_world.get_pelican_convenience_landmark(), + _asset_reservations ) _network_shop.setup( _network_session, @@ -203,7 +230,8 @@ func _ready() -> void: item_catalog, _player.fishing_upgrades, _player.cooler_capacity, - _save_manager + _save_manager, + _asset_reservations ) _fishing_spot.setup( _player, @@ -241,7 +269,9 @@ func _ready() -> void: _network_shop, _network_chat, _network_profile, - _player_spawn_service + _player_spawn_service, + _network_mail, + _asset_reservations ) _water_recovery.setup( _player, diff --git a/main/main.tscn b/main/main.tscn index a897c17..b1b17a5 100644 --- a/main/main.tscn +++ b/main/main.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=26 format=3] +[gd_scene load_steps=28 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"] @@ -25,6 +25,8 @@ [ext_resource type="Script" path="res://network/network_shop_service.gd" id="23_network_shop"] [ext_resource type="Script" path="res://network/network_item_use_service.gd" id="24_network_item"] [ext_resource type="Script" path="res://network/network_chat_service.gd" id="25_network_chat"] +[ext_resource type="Script" path="res://network/network_mail_service.gd" id="26_network_mail"] +[ext_resource type="Script" path="res://progression/player_asset_reservation_service.gd" id="27_reservations"] [node name="Main" type="Node3D"] script = ExtResource("3_main") @@ -69,6 +71,14 @@ script = ExtResource("24_network_item") unique_name_in_owner = true script = ExtResource("25_network_chat") +[node name="PlayerAssetReservationService" type="Node" parent="."] +unique_name_in_owner = true +script = ExtResource("27_reservations") + +[node name="NetworkMailService" type="Node" parent="."] +unique_name_in_owner = true +script = ExtResource("26_network_mail") + [node name="TestWorld" parent="." instance=ExtResource("1_world")] [node name="Players" type="Node3D" parent="."] diff --git a/network/network_item_use_service.gd b/network/network_item_use_service.gd index 53ef170..5fe231e 100644 --- a/network/network_item_use_service.gd +++ b/network/network_item_use_service.gd @@ -20,6 +20,7 @@ var _received_results: Dictionary[String, bool] = {} var _pending_local: Dictionary = {} var _equipped_states: Dictionary[int, Dictionary] = {} var _local_equipped_revision: int = 0 +var _reservations: PlayerAssetReservationService func setup( @@ -29,6 +30,7 @@ func setup( local_bag: PlayerBag, local_effects: PlayerItemEffects, save_manager: PlayerSaveManager, + reservations: PlayerAssetReservationService, ) -> void: _session = session _spawn_service = spawn_service @@ -36,6 +38,7 @@ func setup( _local_bag = local_bag _local_effects = local_effects _save_manager = save_manager + _reservations = reservations _session.peer_removed.connect(_on_peer_removed) _session.state_changed.connect(_on_session_state_changed) _session.peer_authenticated.connect(_on_peer_authenticated) @@ -45,6 +48,12 @@ func request_use(item_id: StringName) -> String: if not _pending_local.is_empty(): local_item_use_finished.emit(false, "An item use is already pending.") return "" + if ( + _reservations != null + and _reservations.get_available_item_quantity(item_id) < 1 + ): + local_item_use_finished.emit(false, "Reserved in a letter.") + return "" if ( _session == null or not _session.is_gameplay_session_active() @@ -169,6 +178,14 @@ func _apply_result(data: Dictionary) -> void: _acknowledge(data, false, str(data["message"])) return var item_id := StringName(str(data["item_id"])) + if ( + _reservations != null + and _reservations.get_available_item_quantity(item_id) < 1 + ): + _pending_local.clear() + local_item_use_finished.emit(false, "Reserved in a letter.") + _acknowledge(data, false, "Reserved in a letter.") + return var bag_snapshot := _local_bag.get_all_items() if ( _local_bag.get_quantity(item_id) < 1 diff --git a/network/network_mail_protocol.gd b/network/network_mail_protocol.gd new file mode 100644 index 0000000..7a1c132 --- /dev/null +++ b/network/network_mail_protocol.gd @@ -0,0 +1,97 @@ +class_name NetworkMailProtocol +extends RefCounted + +const CAPABILITY: StringName = &"mail_v1" +const RELIABLE_CHANNEL: int = NetworkProtocol.MAIL_RELIABLE_CHANNEL +const MAX_ID_LENGTH: int = 96 +const MAX_BODY_CHARACTERS: int = 2000 +const MAX_BODY_BYTES: int = 8000 +const MAX_RECIPIENT_LETTERS: int = 100 +const MAX_SESSION_LETTERS: int = 200 + +const GREETINGS: PackedStringArray = ["dear", "to", "hey", "greetings"] +const SALUTATIONS: PackedStringArray = [ + "love", "from", "cheers", "salutations", "good_luck_have_fun", +] + +enum State { + SENT_UNREAD, + READ, + ACCEPTANCE_PENDING, + ACCEPTED, + DECLINED, + ATTACHMENT_RECALLED, + CANCELLED, + SESSION_ENDED, +} + + +static func sanitize_body(value: Variant) -> String: + if typeof(value) != TYPE_STRING: + return "" + var body := str(value).strip_edges(false, true) + if ( + body.strip_edges().is_empty() + or body.length() > MAX_BODY_CHARACTERS + or body.to_utf8_buffer().size() > MAX_BODY_BYTES + ): + return "" + for index: int in body.length(): + var codepoint := body.unicode_at(index) + if codepoint < 32 and codepoint not in [9, 10, 13]: + return "" + if codepoint == 127: + return "" + return body.replace("\r\n", "\n").replace("\r", "\n").replace("\t", " ") + + +static func valid_id(value: Variant) -> bool: + return ( + typeof(value) == TYPE_STRING + and not str(value).is_empty() + and str(value).length() <= MAX_ID_LENGTH + ) + + +static func validate_send_request(data: Variant) -> bool: + if typeof(data) != TYPE_DICTIONARY: + return false + var value: Dictionary = data + return ( + valid_id(value.get("request_id")) + and valid_id(value.get("session_id")) + and typeof(value.get("recipient_peer_id")) == TYPE_INT + and int(value["recipient_peer_id"]) > 0 + and typeof(value.get("greeting_id")) == TYPE_STRING + and str(value["greeting_id"]) in GREETINGS + and not sanitize_body(value.get("body")).is_empty() + and typeof(value.get("salutation_id")) == TYPE_STRING + and str(value["salutation_id"]) in SALUTATIONS + and typeof(value.get("reservation_id")) == TYPE_STRING + and str(value["reservation_id"]).length() <= MAX_ID_LENGTH + and typeof(value.get("attachment")) == TYPE_DICTIONARY + and Dictionary(value["attachment"]).size() <= 3 + ) + + +static func validate_mail(data: Variant) -> bool: + if typeof(data) != TYPE_DICTIONARY: + return false + var value: Dictionary = data + return ( + valid_id(value.get("mail_id")) + and valid_id(value.get("session_id")) + and typeof(value.get("sequence")) == TYPE_INT + and typeof(value.get("sender_peer_id")) == TYPE_INT + and typeof(value.get("recipient_peer_id")) == TYPE_INT + and typeof(value.get("sender_display_name")) == TYPE_STRING + and str(value["sender_display_name"]).length() <= 24 + and str(value.get("greeting_id", "")) in GREETINGS + and not sanitize_body(value.get("body")).is_empty() + and str(value.get("salutation_id", "")) in SALUTATIONS + and typeof(value.get("attachment")) == TYPE_DICTIONARY + and Dictionary(value["attachment"]).size() <= 3 + and typeof(value.get("state")) == TYPE_INT + and int(value["state"]) >= 0 + and int(value["state"]) < State.size() + ) diff --git a/network/network_mail_protocol.gd.uid b/network/network_mail_protocol.gd.uid new file mode 100644 index 0000000..0359e82 --- /dev/null +++ b/network/network_mail_protocol.gd.uid @@ -0,0 +1 @@ +uid://5ttl0pytgtf7 diff --git a/network/network_mail_service.gd b/network/network_mail_service.gd new file mode 100644 index 0000000..e89f0d3 --- /dev/null +++ b/network/network_mail_service.gd @@ -0,0 +1,802 @@ +class_name NetworkMailService +extends Node + +signal mailbox_changed +signal unread_count_changed(count: int) +signal operation_finished(success: bool, message: String) +signal peers_changed + +const MAX_LEDGER: int = 256 + +var _session: NetworkSession +var _reservations: PlayerAssetReservationService +var _wallet: PlayerWallet +var _inventory: FishInventory +var _bag: PlayerBag +var _collection_log: CollectionLog +var _cooler_capacity: PlayerCoolerCapacity +var _item_catalog: ItemCatalog +var _fish_catalog: FishPool +var _save_manager: PlayerSaveManager + +var _letters: Dictionary[String, Dictionary] = {} +var _local_letters: Dictionary[String, Dictionary] = {} +var _send_ledger: Dictionary[int, Dictionary] = {} +var _action_ledger: Dictionary[String, bool] = {} +var _sequence := 0 +var _pending_local_send: Dictionary = {} +var _pending_transfers: Dictionary[String, Dictionary] = {} +var _local_removal_snapshots: Dictionary[String, Dictionary] = {} +var _received_awards: Dictionary[String, bool] = {} + + +func setup( + session: NetworkSession, + reservations: PlayerAssetReservationService, + wallet: PlayerWallet, + inventory: FishInventory, + bag: PlayerBag, + collection_log: CollectionLog, + cooler_capacity: PlayerCoolerCapacity, + item_catalog: ItemCatalog, + fish_catalog: FishPool, + save_manager: PlayerSaveManager, +) -> void: + _session = session + _reservations = reservations + _wallet = wallet + _inventory = inventory + _bag = bag + _collection_log = collection_log + _cooler_capacity = cooler_capacity + _item_catalog = item_catalog + _fish_catalog = fish_catalog + _save_manager = save_manager + _session.peer_authenticated.connect(func(_id: int, _name: String) -> void: + peers_changed.emit() + ) + _session.peer_removed.connect(_on_peer_removed) + _session.peer_display_name_changed.connect( + func(_id: int, _name: String) -> void: peers_changed.emit() + ) + _session.state_changed.connect(_on_session_state_changed) + + +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(): + values.append(letter.duplicate(true)) + values.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: + var a_unread := int(a["state"]) == NetworkMailProtocol.State.SENT_UNREAD + var b_unread := int(b["state"]) == NetworkMailProtocol.State.SENT_UNREAD + return a_unread != b_unread if a_unread != b_unread else ( + int(a["sequence"]) > int(b["sequence"]) + ) + ) + return values + + +func get_letter(mail_id: String) -> Dictionary: + return _local_letters.get(mail_id, {}).duplicate(true) + + +func get_unread_count() -> int: + var count := 0 + for letter: Dictionary in _local_letters.values(): + if ( + int(letter["recipient_peer_id"]) == _session.get_local_peer_id() + and int(letter["state"]) == NetworkMailProtocol.State.SENT_UNREAD + ): + count += 1 + return count + + +func is_local_recipient(letter: Dictionary) -> bool: + return ( + int(letter.get("recipient_peer_id", 0)) + == _session.get_local_peer_id() + ) + + +func get_local_display_name() -> String: + var record := _session.get_peer_record(_session.get_local_peer_id()) + return record.display_name if record != null else "Player" + + +func get_recipient_choices() -> Array[Dictionary]: + var choices: Array[Dictionary] = [] + var local_id := _session.get_local_peer_id() + for peer_id: int in _session.get_authenticated_peer_ids(): + if peer_id == local_id: + continue + var record := _session.get_peer_record(peer_id) + if record != null: + choices.append({"peer_id": peer_id, "name": record.display_name}) + choices.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: + return str(a["name"]).naturalnocasecmp_to(str(b["name"])) < 0 + ) + var seen: Dictionary[String, int] = {} + for choice: Dictionary in choices: + var name: String = choice["name"] + seen[name] = int(seen.get(name, 0)) + 1 + if seen[name] > 1: + choice["label"] = "%s (%d)" % [name, seen[name]] + else: + choice["label"] = name + return choices + + +func send_letter( + recipient_peer_id: int, + greeting_id: String, + body: String, + salutation_id: String, + attachment: Dictionary, +) -> bool: + if ( + not _pending_local_send.is_empty() + 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 ( + not _session.is_host() + and not _session.supports_server_capability( + NetworkMailProtocol.CAPABILITY + ) + ) + ): + operation_finished.emit(false, "That player is no longer connected.") + return false + var request_id := _new_id("mail_request") + var reservation_id := "" + if int(attachment.get("type", 0)) != 0: + reservation_id = _new_id("reservation") + if not _reservations.reserve(reservation_id, attachment): + operation_finished.emit(false, "That gift is not available.") + return false + var request := { + "request_id": request_id, + "session_id": _session.get_session_id(), + "recipient_peer_id": recipient_peer_id, + "greeting_id": greeting_id, + "body": NetworkMailProtocol.sanitize_body(body), + "salutation_id": salutation_id, + "reservation_id": reservation_id, + "attachment": attachment.duplicate(true), + } + if not NetworkMailProtocol.validate_send_request(request): + if not reservation_id.is_empty(): + _reservations.release(reservation_id) + operation_finished.emit(false, "Letter could not be sent.") + return false + _pending_local_send = request.duplicate(true) + if _session.is_host(): + _handle_send(_session.get_local_peer_id(), request) + else: + submit_mail.rpc_id(1, request) + return true + + +@rpc("any_peer", "call_remote", "reliable", NetworkMailProtocol.RELIABLE_CHANNEL) +func submit_mail(data: Dictionary) -> void: + var sender := multiplayer.get_remote_sender_id() + if _session.is_host() and _session.is_authenticated_peer(sender): + _handle_send(sender, data) + + +func _handle_send(sender: int, data: Dictionary) -> void: + var request_id := str(data.get("request_id", "")) + var ledger: Dictionary = _send_ledger.get(sender, {}) + if ledger.has(request_id): + _deliver_send_result(sender, ledger[request_id]) + return + var error := "" + if ( + not NetworkMailProtocol.validate_send_request(data) + or str(data.get("session_id", "")) != _session.get_session_id() + ): + error = "Letter could not be sent." + var recipient := int(data.get("recipient_peer_id", 0)) + if error.is_empty() and ( + recipient == sender or not _session.is_authenticated_peer(recipient) + ): + error = "That player is no longer connected." + if error.is_empty() and _letters.size() >= NetworkMailProtocol.MAX_SESSION_LETTERS: + error = "The session mailbox is full." + var recipient_count := 0 + for letter: Dictionary in _letters.values(): + if int(letter["recipient_peer_id"]) == recipient: + recipient_count += 1 + if ( + error.is_empty() + and recipient_count >= NetworkMailProtocol.MAX_RECIPIENT_LETTERS + ): + error = "That inbox is full." + var attachment: Dictionary = data.get("attachment", {}) + if error.is_empty() and not _validate_attachment_structure(attachment): + error = "That gift is not valid." + var result := { + "request_id": request_id if not request_id.is_empty() else "invalid", + "accepted": error.is_empty(), + "message": "Letter sent." if error.is_empty() else error, + "reservation_id": str(data.get("reservation_id", "")), + } + if error.is_empty(): + _sequence += 1 + var record := _session.get_peer_record(sender) + var recipient_record := _session.get_peer_record(recipient) + var letter := { + "mail_id": _new_id("mail"), + "session_id": _session.get_session_id(), + "sequence": _sequence, + "sender_peer_id": sender, + "sender_display_name": record.display_name, + "recipient_peer_id": recipient, + "recipient_display_name": recipient_record.display_name, + "greeting_id": str(data["greeting_id"]), + "body": NetworkMailProtocol.sanitize_body(data["body"]), + "salutation_id": str(data["salutation_id"]), + "reservation_id": str(data["reservation_id"]), + "attachment": attachment.duplicate(true), + "state": NetworkMailProtocol.State.SENT_UNREAD, + } + _letters[letter["mail_id"]] = letter + result["mail"] = letter + _deliver_private_letter(sender, letter) + _deliver_private_letter(recipient, letter) + ledger[result["request_id"]] = result.duplicate(true) + _bound(ledger) + _send_ledger[sender] = ledger + _deliver_send_result(sender, result) + + +func _deliver_private_letter(peer_id: int, letter: Dictionary) -> void: + if peer_id == _session.get_local_peer_id(): + _receive_letter(letter) + elif _session.is_authenticated_peer(peer_id): + receive_private_letter.rpc_id(peer_id, letter) + + +@rpc("authority", "call_remote", "reliable", NetworkMailProtocol.RELIABLE_CHANNEL) +func receive_private_letter(letter: Dictionary) -> void: + _receive_letter(letter) + + +func _receive_letter(letter: Dictionary) -> void: + if ( + not NetworkMailProtocol.validate_mail(letter) + or str(letter["session_id"]) != _session.get_session_id() + or _session.get_local_peer_id() not in [ + int(letter["sender_peer_id"]), int(letter["recipient_peer_id"]), + ] + ): + return + _local_letters[letter["mail_id"]] = letter.duplicate(true) + _emit_mailbox() + + +func _deliver_send_result(peer_id: int, result: Dictionary) -> void: + if peer_id == _session.get_local_peer_id(): + _receive_send_result(result) + else: + receive_send_result.rpc_id(peer_id, result) + + +@rpc("authority", "call_remote", "reliable", NetworkMailProtocol.RELIABLE_CHANNEL) +func receive_send_result(result: Dictionary) -> void: + _receive_send_result(result) + + +func _receive_send_result(result: Dictionary) -> void: + if ( + _pending_local_send.is_empty() + or str(result.get("request_id", "")) + != str(_pending_local_send.get("request_id", "")) + ): + return + if not bool(result.get("accepted", false)): + var reservation_id := str(_pending_local_send.get("reservation_id", "")) + if not reservation_id.is_empty(): + _reservations.release(reservation_id) + _pending_local_send.clear() + operation_finished.emit( + bool(result.get("accepted", false)), + str(result.get("message", "Letter could not be sent.")) + ) + + +func mark_read(mail_id: String) -> void: + var request := _action_request(mail_id) + if request.is_empty(): + return + if _session.is_host(): + _handle_read(_session.get_local_peer_id(), request) + else: + submit_read.rpc_id(1, request) + + +@rpc("any_peer", "call_remote", "reliable", NetworkMailProtocol.RELIABLE_CHANNEL) +func submit_read(data: Dictionary) -> void: + var sender := multiplayer.get_remote_sender_id() + if _session.is_host() and _session.is_authenticated_peer(sender): + _handle_read(sender, data) + + +func _handle_read(peer_id: int, data: Dictionary) -> void: + var action_id := str(data.get("request_id", "")) + if _action_ledger.has(action_id): + return + var letter: Dictionary = _letters.get(str(data.get("mail_id", "")), {}) + if ( + letter.is_empty() + or int(letter["recipient_peer_id"]) != peer_id + or str(data.get("session_id", "")) != _session.get_session_id() + ): + return + _action_ledger[action_id] = true + if int(letter["state"]) == NetworkMailProtocol.State.SENT_UNREAD: + letter["state"] = NetworkMailProtocol.State.READ + _update_participants(letter) + + +func accept_gift(mail_id: String) -> void: + var request := _action_request(mail_id) + if request.is_empty(): + return + if _session.is_host(): + _handle_accept(_session.get_local_peer_id(), request) + else: + submit_accept.rpc_id(1, request) + + +@rpc("any_peer", "call_remote", "reliable", NetworkMailProtocol.RELIABLE_CHANNEL) +func submit_accept(data: Dictionary) -> void: + var sender := multiplayer.get_remote_sender_id() + if _session.is_host() and _session.is_authenticated_peer(sender): + _handle_accept(sender, data) + + +func _handle_accept(peer_id: int, data: Dictionary) -> void: + var action_id := str(data.get("request_id", "")) + var letter: Dictionary = _letters.get(str(data.get("mail_id", "")), {}) + if _action_ledger.has(action_id): + return + if ( + letter.is_empty() + or int(letter["recipient_peer_id"]) != peer_id + or int(letter["state"]) not in [ + NetworkMailProtocol.State.SENT_UNREAD, + NetworkMailProtocol.State.READ, + ] + or int(letter["attachment"].get("type", 0)) == 0 + or not _session.is_authenticated_peer(int(letter["sender_peer_id"])) + ): + return + _action_ledger[action_id] = true + var transfer_id := _new_id("mail_transfer") + letter["state"] = NetworkMailProtocol.State.ACCEPTANCE_PENDING + _pending_transfers[transfer_id] = { + "letter": letter, + "recipient_prepared": false, + "sender_removed": false, + } + _update_participants(letter) + _request_prepare(int(letter["recipient_peer_id"]), transfer_id, letter) + + +func _request_prepare(peer_id: int, transfer_id: String, letter: Dictionary) -> void: + if peer_id == _session.get_local_peer_id(): + _prepare_recipient(transfer_id, letter) + else: + prepare_recipient.rpc_id(peer_id, transfer_id, letter) + + +@rpc("authority", "call_remote", "reliable", NetworkMailProtocol.RELIABLE_CHANNEL) +func prepare_recipient(transfer_id: String, letter: Dictionary) -> void: + _prepare_recipient(transfer_id, letter) + + +func _prepare_recipient(transfer_id: String, letter: Dictionary) -> void: + var ready := _can_receive(letter["attachment"]) + _send_phase_ack("recipient_prepared", transfer_id, ready) + + +func _request_sender_commit(peer_id: int, transfer_id: String, letter: Dictionary) -> void: + if peer_id == _session.get_local_peer_id(): + _commit_sender(transfer_id, letter) + else: + commit_sender.rpc_id(peer_id, transfer_id, letter) + + +@rpc("authority", "call_remote", "reliable", NetworkMailProtocol.RELIABLE_CHANNEL) +func commit_sender(transfer_id: String, letter: Dictionary) -> void: + _commit_sender(transfer_id, letter) + + +func _commit_sender(transfer_id: String, letter: Dictionary) -> void: + var reservation_id: String = letter["reservation_id"] + var snapshot := _capture_assets() + var applied := ( + _reservations.has_reservation(reservation_id) + and _reservations.commit_removal(reservation_id) + and _save_manager.save_if_dirty() + ) + if not applied: + _restore_assets(snapshot) + _save_manager.save_if_dirty() + else: + _local_removal_snapshots[transfer_id] = snapshot + _send_phase_ack("sender_removed", transfer_id, applied) + + +func _request_recipient_award( + peer_id: int, transfer_id: String, letter: Dictionary +) -> void: + if peer_id == _session.get_local_peer_id(): + _award_recipient(transfer_id, letter) + else: + award_recipient.rpc_id(peer_id, transfer_id, letter) + + +@rpc("authority", "call_remote", "reliable", NetworkMailProtocol.RELIABLE_CHANNEL) +func award_recipient(transfer_id: String, letter: Dictionary) -> void: + _award_recipient(transfer_id, letter) + + +func _award_recipient(transfer_id: String, letter: Dictionary) -> void: + if _received_awards.has(transfer_id): + _send_phase_ack("recipient_awarded", transfer_id, true) + return + var snapshot := _capture_assets() + var applied := _apply_award(letter["attachment"]) + if applied: + applied = _save_manager.save_if_dirty() + if not applied: + _restore_assets(snapshot) + _save_manager.save_if_dirty() + else: + _received_awards[transfer_id] = true + _bound(_received_awards) + _send_phase_ack("recipient_awarded", transfer_id, applied) + + +func _send_phase_ack(phase: String, transfer_id: String, applied: bool) -> void: + if _session.is_host(): + _handle_phase_ack(_session.get_local_peer_id(), phase, transfer_id, applied) + else: + acknowledge_transfer_phase.rpc_id(1, phase, transfer_id, applied) + + +@rpc("any_peer", "call_remote", "reliable", NetworkMailProtocol.RELIABLE_CHANNEL) +func acknowledge_transfer_phase( + phase: String, transfer_id: String, applied: bool +) -> void: + var sender := multiplayer.get_remote_sender_id() + if _session.is_host() and _session.is_authenticated_peer(sender): + _handle_phase_ack(sender, phase, transfer_id, applied) + + +func _handle_phase_ack( + peer_id: int, phase: String, transfer_id: String, applied: bool +) -> void: + var transfer: Dictionary = _pending_transfers.get(transfer_id, {}) + if transfer.is_empty(): + return + var letter: Dictionary = transfer["letter"] + if phase == "recipient_prepared": + if peer_id != int(letter["recipient_peer_id"]): + return + if not applied: + _finish_transfer(transfer_id, false, "Gift could not be transferred.") + return + transfer["recipient_prepared"] = true + _request_sender_commit(int(letter["sender_peer_id"]), transfer_id, letter) + elif phase == "sender_removed": + if peer_id != int(letter["sender_peer_id"]): + return + if not applied: + _finish_transfer(transfer_id, false, "Gift returned to sender.") + return + transfer["sender_removed"] = true + _request_recipient_award( + int(letter["recipient_peer_id"]), transfer_id, letter + ) + elif phase == "recipient_awarded": + if peer_id != int(letter["recipient_peer_id"]): + return + if applied: + _finish_transfer(transfer_id, true, "Gift accepted.") + else: + _request_sender_rollback( + int(letter["sender_peer_id"]), transfer_id + ) + _finish_transfer(transfer_id, false, "Gift could not be transferred.") + + +func _request_sender_rollback(peer_id: int, transfer_id: String) -> void: + if peer_id == _session.get_local_peer_id(): + _rollback_sender(transfer_id) + else: + rollback_sender.rpc_id(peer_id, transfer_id) + + +@rpc("authority", "call_remote", "reliable", NetworkMailProtocol.RELIABLE_CHANNEL) +func rollback_sender(transfer_id: String) -> void: + _rollback_sender(transfer_id) + + +func _rollback_sender(transfer_id: String) -> void: + var snapshot: Dictionary = _local_removal_snapshots.get(transfer_id, {}) + if not snapshot.is_empty(): + _restore_assets(snapshot) + _save_manager.save_if_dirty() + _local_removal_snapshots.erase(transfer_id) + + +func _finish_transfer(transfer_id: String, accepted: bool, message: String) -> void: + var transfer: Dictionary = _pending_transfers.get(transfer_id, {}) + if transfer.is_empty(): + return + var letter: Dictionary = transfer["letter"] + letter["state"] = ( + NetworkMailProtocol.State.ACCEPTED + if accepted else NetworkMailProtocol.State.ATTACHMENT_RECALLED + ) + _pending_transfers.erase(transfer_id) + _update_participants(letter) + operation_finished.emit(accepted, message) + + +func decline_gift(mail_id: String) -> void: + var request := _action_request(mail_id) + if request.is_empty(): + return + if _session.is_host(): + _handle_decline(_session.get_local_peer_id(), request) + else: + submit_decline.rpc_id(1, request) + + +@rpc("any_peer", "call_remote", "reliable", NetworkMailProtocol.RELIABLE_CHANNEL) +func submit_decline(data: Dictionary) -> void: + var sender := multiplayer.get_remote_sender_id() + if _session.is_host() and _session.is_authenticated_peer(sender): + _handle_decline(sender, data) + + +func _handle_decline(peer_id: int, data: Dictionary) -> void: + var action_id := str(data.get("request_id", "")) + var letter: Dictionary = _letters.get(str(data.get("mail_id", "")), {}) + if _action_ledger.has(action_id): + return + if ( + letter.is_empty() + or int(letter["recipient_peer_id"]) != peer_id + or int(letter["state"]) not in [ + NetworkMailProtocol.State.SENT_UNREAD, + NetworkMailProtocol.State.READ, + ] + ): + return + _action_ledger[action_id] = true + letter["state"] = NetworkMailProtocol.State.DECLINED + _request_release(int(letter["sender_peer_id"]), letter["reservation_id"]) + _update_participants(letter) + + +func _request_release(peer_id: int, reservation_id: String) -> void: + if reservation_id.is_empty(): + return + if peer_id == _session.get_local_peer_id(): + _reservations.release(reservation_id) + else: + release_reservation.rpc_id(peer_id, reservation_id) + + +@rpc("authority", "call_remote", "reliable", NetworkMailProtocol.RELIABLE_CHANNEL) +func release_reservation(reservation_id: String) -> void: + _reservations.release(reservation_id) + + +func _update_participants(letter: Dictionary) -> void: + _letters[letter["mail_id"]] = letter + _deliver_private_letter(int(letter["sender_peer_id"]), letter) + _deliver_private_letter(int(letter["recipient_peer_id"]), letter) + + +func _action_request(mail_id: String) -> Dictionary: + if mail_id.is_empty() or not _local_letters.has(mail_id): + return {} + return { + "request_id": _new_id("mail_action"), + "mail_id": mail_id, + "session_id": _session.get_session_id(), + } + + +func _validate_attachment_structure(attachment: Dictionary) -> bool: + if typeof(attachment.get("type")) != TYPE_INT: + return false + match int(attachment["type"]): + PlayerAssetReservationService.AttachmentType.NONE: + return attachment.size() == 1 + PlayerAssetReservationService.AttachmentType.FISH_COIN: + return ( + attachment.size() == 2 + and + typeof(attachment.get("amount")) == TYPE_INT + and int(attachment["amount"]) >= 1 + and int(attachment["amount"]) <= 1000000000 + ) + PlayerAssetReservationService.AttachmentType.FISH: + if not ( + attachment.size() == 3 + and + typeof(attachment.get("catch_id")) == TYPE_STRING + and typeof(attachment.get("catch")) == TYPE_DICTIONARY + ): + return false + var fish_catch := _decode_catch(attachment) + return ( + fish_catch != null + and str(fish_catch.catch_id) == str(attachment["catch_id"]) + and not bool( + Dictionary(attachment["catch"]).get("is_favorited", false) + ) + ) + PlayerAssetReservationService.AttachmentType.CONSUMABLE: + var item := _item_catalog.get_item_by_id( + StringName(str(attachment.get("item_id", ""))) + ) + return ( + attachment.size() == 3 + and + item != null + and item.category == ItemData.Category.CONSUMABLE + and typeof(attachment.get("quantity")) == TYPE_INT + and int(attachment["quantity"]) >= 1 + and int(attachment["quantity"]) <= item.max_stack + ) + return false + + +func _can_receive(attachment: Dictionary) -> bool: + match int(attachment.get("type", 0)): + PlayerAssetReservationService.AttachmentType.FISH_COIN: + return _wallet.can_credit(int(attachment["amount"])) + PlayerAssetReservationService.AttachmentType.FISH: + return ( + _inventory.get_all_catches().size() < _cooler_capacity.get_capacity() + and not _inventory.contains_catch_id( + StringName(str(attachment["catch_id"])) + ) + and _decode_catch(attachment) != null + ) + PlayerAssetReservationService.AttachmentType.CONSUMABLE: + return _bag.can_add_item( + StringName(str(attachment["item_id"])), + int(attachment["quantity"]) + ) + return false + + +func _apply_award(attachment: Dictionary) -> bool: + if not _can_receive(attachment): + return false + match int(attachment["type"]): + PlayerAssetReservationService.AttachmentType.FISH_COIN: + return _wallet.credit(int(attachment["amount"])) + PlayerAssetReservationService.AttachmentType.FISH: + var fish_catch := _decode_catch(attachment) + _inventory.add_catch(fish_catch) + _collection_log.mark_discovered(fish_catch.fish_id) + return _inventory.contains_catch_id(fish_catch.catch_id) + PlayerAssetReservationService.AttachmentType.CONSUMABLE: + return _bag.add_item( + StringName(str(attachment["item_id"])), + int(attachment["quantity"]) + ) + return false + + +func _decode_catch(attachment: Dictionary) -> FishCatch: + var data: Dictionary = attachment.get("catch", {}) + var fish := _fish_catalog.get_fish_by_id( + StringName(str(data.get("fish_id", ""))) + ) + return FishCatch.from_network_dict(data, fish) + + +func _capture_assets() -> Dictionary: + return { + "wallet": _wallet.get_balance(), + "bag": _bag.get_all_items(), + "catches": _inventory.get_all_catches(), + "next_sequence": _inventory.get_next_catch_sequence(), + "discovered": _collection_log.get_discovered_ids(), + } + + +func _restore_assets(snapshot: Dictionary) -> void: + _wallet.restore_balance(int(snapshot["wallet"])) + _bag.replace_all_items(snapshot["bag"]) + _inventory.replace_all_catches( + snapshot["catches"], int(snapshot["next_sequence"]) + ) + _collection_log.replace_discovered_ids(snapshot["discovered"]) + + +func _emit_mailbox() -> void: + mailbox_changed.emit() + unread_count_changed.emit(get_unread_count()) + + +func _on_peer_removed(peer_id: int) -> void: + if not _session.is_host(): + _clear_local_mail_for_peer(peer_id) + return + for letter: Dictionary in _letters.values(): + if int(letter["recipient_peer_id"]) == peer_id: + if int(letter["state"]) in [ + NetworkMailProtocol.State.SENT_UNREAD, + NetworkMailProtocol.State.READ, + ]: + letter["state"] = NetworkMailProtocol.State.CANCELLED + _request_release( + int(letter["sender_peer_id"]), letter["reservation_id"] + ) + _deliver_private_letter( + int(letter["sender_peer_id"]), letter + ) + elif ( + int(letter["sender_peer_id"]) == peer_id + and int(letter["state"]) in [ + NetworkMailProtocol.State.SENT_UNREAD, + NetworkMailProtocol.State.READ, + ] + ): + letter["state"] = NetworkMailProtocol.State.ATTACHMENT_RECALLED + _update_participants(letter) + _send_ledger.erase(peer_id) + _clear_local_mail_for_peer(peer_id) + peers_changed.emit() + + +func _clear_local_mail_for_peer(peer_id: int) -> void: + for mail_id: String in _local_letters.keys(): + var letter: Dictionary = _local_letters[mail_id] + if int(letter["recipient_peer_id"]) == peer_id: + _local_letters.erase(mail_id) + _emit_mailbox() + + +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, + ]: + _reservations.release_all() + _letters.clear() + _local_letters.clear() + _send_ledger.clear() + _action_ledger.clear() + _pending_local_send.clear() + _pending_transfers.clear() + _local_removal_snapshots.clear() + _received_awards.clear() + _sequence = 0 + _emit_mailbox() + + +func _bound(values: Dictionary) -> void: + while values.size() > MAX_LEDGER: + values.erase(values.keys().front()) + + +func _new_id(prefix: String) -> String: + return "%s:%s" % [ + prefix, Crypto.new().generate_random_bytes(16).hex_encode(), + ] diff --git a/network/network_mail_service.gd.uid b/network/network_mail_service.gd.uid new file mode 100644 index 0000000..4a16d79 --- /dev/null +++ b/network/network_mail_service.gd.uid @@ -0,0 +1 @@ +uid://t235k67v0dno diff --git a/network/network_protocol.gd b/network/network_protocol.gd index 419e0c1..f4ede30 100644 --- a/network/network_protocol.gd +++ b/network/network_protocol.gd @@ -9,12 +9,13 @@ const MAX_NONCE_LENGTH: int = 96 # ENet channels: 0 reliable lifecycle, 1 movement input, 2 movement # snapshots, 3 fishing input, 4 fishing snapshots, 5 reliable sales, # 6 reliable shop transactions, 7 reliable item/equipment lifecycle, -# 8 reliable ordered session chat. +# 8 reliable ordered session chat, 9 reliable private session mail. const SALE_RELIABLE_CHANNEL: int = 5 const SHOP_RELIABLE_CHANNEL: int = 6 const ITEM_RELIABLE_CHANNEL: int = 7 const CHAT_RELIABLE_CHANNEL: int = 8 -const ENET_CHANNEL_COUNT: int = 9 +const MAIL_RELIABLE_CHANNEL: int = 9 +const ENET_CHANNEL_COUNT: int = 10 enum RejectionCode { NONE, @@ -116,6 +117,7 @@ static func make_server_hello( "item_use_v1", "equipment_v1", "chat_v1", + "mail_v1", ]), } diff --git a/network/network_sale_service.gd b/network/network_sale_service.gd index 56b8e72..ba2a141 100644 --- a/network/network_sale_service.gd +++ b/network/network_sale_service.gd @@ -38,6 +38,7 @@ var _applied_results: Dictionary[String, bool] = {} var _received_results: Dictionary[String, bool] = {} var _pending_local_request_id: String = "" var _pending_local_catch_ids: Array[StringName] = [] +var _reservations: PlayerAssetReservationService func setup( @@ -51,6 +52,7 @@ func setup( fish_catalog: FishPoolType, buyer: FishBuyerProfileType, pelican_landmark: Node3D, + reservations: PlayerAssetReservationService, ) -> void: _session = session _spawn_service = spawn_service @@ -62,6 +64,7 @@ func setup( _fish_catalog = fish_catalog _buyer = buyer _pelican_landmark = pelican_landmark + _reservations = reservations if not _session.peer_removed.is_connected(_on_peer_removed): _session.peer_removed.connect(_on_peer_removed) if not _session.state_changed.is_connected(_on_session_state_changed): @@ -86,6 +89,12 @@ func is_local_sale_pending() -> bool: func request_local_sale(catch_ids: Array[StringName]) -> String: + for catch_id: StringName in catch_ids: + if _reservations != null and _reservations.is_fish_reserved(catch_id): + local_sale_finished.emit( + "", false, "Reserved in a letter.", [], 0 + ) + return "" if is_local_sale_pending(): local_sale_finished.emit( "", false, "Selling…", [], 0 @@ -346,6 +355,10 @@ func _apply_sale_result(data: Dictionary) -> void: if catch_ids != _pending_local_catch_ids: _fail_local_apply(data, "Sale could not be completed.") return + for catch_id: StringName in catch_ids: + if _reservations != null and _reservations.is_fish_reserved(catch_id): + _fail_local_apply(data, "Reserved in a letter.") + return var preview: FishSaleResultType = _sale_service.preview_batch( catch_ids, _buyer ) diff --git a/network/network_session.gd b/network/network_session.gd index 8948578..d2e9c83 100644 --- a/network/network_session.gd +++ b/network/network_session.gd @@ -277,6 +277,7 @@ func supports_server_capability(capability: StringName) -> bool: return str(capability) in PackedStringArray([ "movement_v1", "fishing_v1", "sale_v1", "shop_v1", "item_use_v1", "equipment_v1", "chat_v1", + "mail_v1", ]) return str(capability) in _server_capabilities @@ -285,6 +286,10 @@ func get_peer_record(peer_id: int) -> PeerRegistry.PeerRecord: return _registry.get_peer(peer_id) +func get_authenticated_peer_ids() -> Array[int]: + return _registry.get_peer_ids() + + func update_local_display_name(value: String) -> bool: if _profile == null or not _profile.set_display_name(value): return false diff --git a/network/network_shop_service.gd b/network/network_shop_service.gd index ba92158..bdfbca2 100644 --- a/network/network_shop_service.gd +++ b/network/network_shop_service.gd @@ -41,6 +41,7 @@ var _acknowledged_results: Dictionary[String, bool] = {} var _received_results: Dictionary[String, bool] = {} var _applied_results: Dictionary[String, bool] = {} var _pending_local_request: Dictionary = {} +var _reservations: PlayerAssetReservationService func setup( @@ -54,6 +55,7 @@ func setup( upgrades: PlayerFishingUpgrades, cooler_capacity: PlayerCoolerCapacity, save_manager: PlayerSaveManager, + reservations: PlayerAssetReservationService, ) -> void: _session = session _spawn_service = spawn_service @@ -65,6 +67,7 @@ func setup( _upgrades = upgrades _cooler_capacity = cooler_capacity _save_manager = save_manager + _reservations = reservations if not _session.peer_removed.is_connected(_on_peer_removed): _session.peer_removed.connect(_on_peer_removed) if not _session.state_changed.is_connected(_on_session_state_changed): @@ -464,6 +467,11 @@ func _validate_local_result(data: Dictionary) -> String: var product_id := StringName(str(data["product_id"])) var expected_state: int = data["expected_state"] var cost: int = data["total_cost"] + if ( + _reservations != null + and _reservations.get_available_fish_coin() < cost + ): + return "Reserved in a letter." if int(data["resulting_state"]) != expected_state + 1: return "Purchase could not be completed." if not _wallet.can_afford(cost): diff --git a/progression/player_asset_reservation_service.gd b/progression/player_asset_reservation_service.gd new file mode 100644 index 0000000..2b7d38f --- /dev/null +++ b/progression/player_asset_reservation_service.gd @@ -0,0 +1,200 @@ +class_name PlayerAssetReservationService +extends Node + +enum AttachmentType { NONE, FISH_COIN, FISH, CONSUMABLE } + +signal reservations_changed + +var _wallet: PlayerWallet +var _inventory: FishInventory +var _bag: PlayerBag +var _catalog: ItemCatalog +var _reservations: Dictionary[String, Dictionary] = {} + + +func setup( + wallet: PlayerWallet, + inventory: FishInventory, + bag: PlayerBag, + catalog: ItemCatalog, +) -> void: + _wallet = wallet + _inventory = inventory + _bag = bag + _catalog = catalog + + +func reserve(reservation_id: String, attachment: Dictionary) -> bool: + if ( + reservation_id.is_empty() + or reservation_id.length() > 96 + or _reservations.has(reservation_id) + or not validate_attachment(attachment) + ): + return false + var kind: int = attachment["type"] + match kind: + AttachmentType.NONE: + return true + AttachmentType.FISH_COIN: + if int(attachment["amount"]) > get_available_fish_coin(): + return false + AttachmentType.FISH: + var catch_id := StringName(str(attachment["catch_id"])) + var fish_catch := _inventory.get_catch_by_id(catch_id) + if ( + fish_catch == null + or fish_catch.is_favorited + or is_fish_reserved(catch_id) + ): + return false + AttachmentType.CONSUMABLE: + var item_id := StringName(str(attachment["item_id"])) + if ( + int(attachment["quantity"]) + > get_available_item_quantity(item_id) + ): + return false + _reservations[reservation_id] = attachment.duplicate(true) + reservations_changed.emit() + return true + + +func release(reservation_id: String) -> bool: + if not _reservations.erase(reservation_id): + return false + reservations_changed.emit() + return true + + +func release_all() -> void: + if _reservations.is_empty(): + return + _reservations.clear() + reservations_changed.emit() + + +func has_reservation(reservation_id: String) -> bool: + return _reservations.has(reservation_id) + + +func get_reservation(reservation_id: String) -> Dictionary: + return _reservations.get(reservation_id, {}).duplicate(true) + + +func get_available_fish_coin() -> int: + return maxi(_wallet.get_balance() - get_reserved_fish_coin(), 0) + + +func get_reserved_fish_coin() -> int: + var total := 0 + for attachment: Dictionary in _reservations.values(): + if int(attachment.get("type", 0)) == AttachmentType.FISH_COIN: + total += int(attachment.get("amount", 0)) + return total + + +func get_available_item_quantity(item_id: StringName) -> int: + return maxi(_bag.get_quantity(item_id) - get_reserved_item_quantity(item_id), 0) + + +func get_reserved_item_quantity(item_id: StringName) -> int: + var total := 0 + for attachment: Dictionary in _reservations.values(): + if ( + int(attachment.get("type", 0)) == AttachmentType.CONSUMABLE + and StringName(str(attachment.get("item_id", ""))) == item_id + ): + total += int(attachment.get("quantity", 0)) + return total + + +func is_fish_reserved(catch_id: StringName) -> bool: + for attachment: Dictionary in _reservations.values(): + if ( + int(attachment.get("type", 0)) == AttachmentType.FISH + and StringName(str(attachment.get("catch_id", ""))) == catch_id + ): + return true + return false + + +func can_commit(reservation_id: String) -> bool: + var attachment := get_reservation(reservation_id) + if attachment.is_empty(): + return false + match int(attachment["type"]): + AttachmentType.FISH_COIN: + return _wallet.can_afford(int(attachment["amount"])) + AttachmentType.FISH: + return _inventory.get_catch_by_id( + StringName(str(attachment["catch_id"])) + ) != null + AttachmentType.CONSUMABLE: + return ( + _bag.get_quantity(StringName(str(attachment["item_id"]))) + >= int(attachment["quantity"]) + ) + return false + + +func commit_removal(reservation_id: String) -> bool: + var attachment := get_reservation(reservation_id) + if attachment.is_empty() or not can_commit(reservation_id): + return false + var applied := false + match int(attachment["type"]): + AttachmentType.FISH_COIN: + applied = _wallet.debit(int(attachment["amount"])) + AttachmentType.FISH: + applied = _inventory.remove_catch_by_id( + StringName(str(attachment["catch_id"])) + ) != null + AttachmentType.CONSUMABLE: + applied = _bag.remove_item( + StringName(str(attachment["item_id"])), + int(attachment["quantity"]) + ) + if applied: + release(reservation_id) + return applied + + +func validate_attachment(attachment: Dictionary) -> bool: + if typeof(attachment.get("type")) != TYPE_INT: + return false + match int(attachment["type"]): + AttachmentType.NONE: + return attachment.size() == 1 + AttachmentType.FISH_COIN: + return ( + attachment.size() == 2 + and + typeof(attachment.get("amount")) == TYPE_INT + and int(attachment["amount"]) >= 1 + and int(attachment["amount"]) <= 1000000000 + ) + AttachmentType.FISH: + return ( + attachment.size() == 3 + and + typeof(attachment.get("catch_id")) == TYPE_STRING + and not str(attachment["catch_id"]).is_empty() + and str(attachment["catch_id"]).length() <= 160 + and typeof(attachment.get("catch")) == TYPE_DICTIONARY + ) + AttachmentType.CONSUMABLE: + if ( + attachment.size() != 3 + or + typeof(attachment.get("item_id")) != TYPE_STRING + or typeof(attachment.get("quantity")) != TYPE_INT + or int(attachment["quantity"]) < 1 + or int(attachment["quantity"]) > 999 + ): + return false + var item := _catalog.get_item_by_id( + StringName(str(attachment["item_id"])) + ) + return item != null and item.category == ItemData.Category.CONSUMABLE + return false diff --git a/progression/player_asset_reservation_service.gd.uid b/progression/player_asset_reservation_service.gd.uid new file mode 100644 index 0000000..aa45786 --- /dev/null +++ b/progression/player_asset_reservation_service.gd.uid @@ -0,0 +1 @@ +uid://bheplri3k6ch7 diff --git a/ui/chat_ui.gd b/ui/chat_ui.gd index 9b5dd65..3926f13 100644 --- a/ui/chat_ui.gd +++ b/ui/chat_ui.gd @@ -190,7 +190,7 @@ func _on_message(message: Dictionary) -> void: } -func _on_history(_messages: Array[Dictionary]) -> void: +func _on_history(_messages: Array) -> void: if _messages.is_empty(): for peer_id: int in _speech.keys(): _on_peer_removed(peer_id) diff --git a/ui/game_ui.gd b/ui/game_ui.gd index 3780ab0..d635269 100644 --- a/ui/game_ui.gd +++ b/ui/game_ui.gd @@ -116,6 +116,8 @@ func setup( network_chat_service: NetworkChatService, network_profile: NetworkProfilePreferences, spawn_service: PlayerSpawnService, + network_mail_service: NetworkMailService, + reservations: PlayerAssetReservationService, ) -> void: _fishing_spot = fishing_spot _item_effects = item_effects @@ -149,7 +151,9 @@ func setup( item_catalog, cooler_capacity, network_session, - network_sale_service + network_sale_service, + network_mail_service, + reservations ) _hotbar_ui.setup(hotbar, bag, item_catalog, fishing_spot) _fishing_shop.setup( diff --git a/ui/mail_page.gd b/ui/mail_page.gd new file mode 100644 index 0000000..55d1583 --- /dev/null +++ b/ui/mail_page.gd @@ -0,0 +1,603 @@ +class_name MailPage +extends Control + +signal focus_requested + +const GREETING_LABELS := { + "dear": "Dear", + "to": "To", + "hey": "Hey", + "greetings": "Greetings", +} +const SALUTATION_LABELS := { + "love": "Love", + "from": "From", + "cheers": "Cheers", + "salutations": "Salutations", + "good_luck_have_fun": "Good Luck Have Fun", +} + +var _service: NetworkMailService +var _reservations: PlayerAssetReservationService +var _inventory: FishInventory +var _wallet: PlayerWallet +var _bag: PlayerBag +var _catalog: ItemCatalog + +var _inbox: Control +var _compose: Control +var _letter: Control +var _inbox_list: VBoxContainer +var _empty_label: Label +var _recipient: OptionButton +var _greeting: OptionButton +var _body: TextEdit +var _salutation: OptionButton +var _signature: Label +var _attachment_kind: OptionButton +var _attachment_choice: OptionButton +var _attachment_amount: SpinBox +var _attachment_summary: Label +var _send_button: Button +var _status: Label +var _letter_text: Label +var _letter_gift: Label +var _accept: Button +var _decline: Button +var _current_mail_id := "" + + +func _ready() -> void: + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + _build_ui() + + +func setup( + service: NetworkMailService, + reservations: PlayerAssetReservationService, + inventory: FishInventory, + wallet: PlayerWallet, + bag: PlayerBag, + catalog: ItemCatalog, +) -> void: + _service = service + _reservations = reservations + _inventory = inventory + _wallet = wallet + _bag = bag + _catalog = catalog + _service.mailbox_changed.connect(_refresh_inbox) + _service.peers_changed.connect(_refresh_recipients) + _service.operation_finished.connect(_on_operation_finished) + _refresh_inbox() + _refresh_recipients() + + +func activate() -> void: + _show_inbox() + call_deferred("_focus_first") + + +func deactivate() -> void: + _current_mail_id = "" + _show_inbox() + + +func consume_escape() -> bool: + if _compose.visible or _letter.visible: + _show_inbox() + return true + return false + + +func set_interactive(value: bool) -> void: + mouse_filter = Control.MOUSE_FILTER_PASS if value else Control.MOUSE_FILTER_IGNORE + for node: Node in find_children("*", "BaseButton", true, false): + (node as BaseButton).focus_mode = ( + Control.FOCUS_ALL if value and node.is_visible_in_tree() + else Control.FOCUS_NONE + ) + + +func _build_ui() -> void: + var paper := PanelContainer.new() + paper.position = Vector2(116, 126) + paper.size = Vector2(1048, 470) + var style := StyleBoxFlat.new() + style.bg_color = Color("f3ecd7") + style.border_color = Color("665a46") + style.set_border_width_all(4) + style.set_corner_radius_all(18) + style.shadow_color = Color(0.05, 0.04, 0.03, 0.42) + style.shadow_size = 7 + paper.add_theme_stylebox_override("panel", style) + add_child(paper) + var margin := MarginContainer.new() + margin.add_theme_constant_override("margin_left", 34) + margin.add_theme_constant_override("margin_right", 34) + margin.add_theme_constant_override("margin_top", 28) + margin.add_theme_constant_override("margin_bottom", 28) + paper.add_child(margin) + var root := Control.new() + margin.add_child(root) + _inbox = _build_inbox() + _compose = _build_compose() + _letter = _build_letter() + for page: Control in [_inbox, _compose, _letter]: + page.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + root.add_child(page) + _status = Label.new() + _status.position = Vector2(18, 430) + _status.size = Vector2(900, 28) + _status.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _status.add_theme_color_override("font_color", Color("4a3f31")) + root.add_child(_status) + _show_inbox() + + +func _build_inbox() -> Control: + var page := Control.new() + var title := Label.new() + title.text = "session mail" + title.position = Vector2(12, 0) + title.size = Vector2(500, 42) + title.add_theme_font_size_override("font_size", 30) + page.add_child(title) + var send := Button.new() + send.text = "send mail" + send.position = Vector2(820, 0) + send.size = Vector2(150, 48) + send.pressed.connect(_show_compose) + page.add_child(send) + var scroll := ScrollContainer.new() + scroll.position = Vector2(12, 62) + scroll.size = Vector2(958, 342) + page.add_child(scroll) + _inbox_list = VBoxContainer.new() + _inbox_list.custom_minimum_size = Vector2(930, 0) + _inbox_list.add_theme_constant_override("separation", 8) + scroll.add_child(_inbox_list) + _empty_label = Label.new() + _empty_label.text = "No letters yet." + _empty_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _empty_label.custom_minimum_size = Vector2(930, 80) + _inbox_list.add_child(_empty_label) + return page + + +func _build_compose() -> Control: + var page := Control.new() + var title := Label.new() + title.text = "write a letter" + title.position = Vector2(12, 0) + title.size = Vector2(300, 36) + title.add_theme_font_size_override("font_size", 26) + page.add_child(title) + _greeting = OptionButton.new() + _greeting.position = Vector2(12, 48) + _greeting.size = Vector2(180, 46) + for id: String in NetworkMailProtocol.GREETINGS: + _greeting.add_item(GREETING_LABELS[id]) + _greeting.set_item_metadata(_greeting.item_count - 1, id) + page.add_child(_greeting) + _recipient = OptionButton.new() + _recipient.position = Vector2(204, 48) + _recipient.size = Vector2(330, 46) + page.add_child(_recipient) + _body = TextEdit.new() + _body.position = Vector2(12, 106) + _body.size = Vector2(650, 218) + _body.placeholder_text = "write your letter…" + _body.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY + _body.text_changed.connect(_update_send_state) + page.add_child(_body) + _salutation = OptionButton.new() + _salutation.position = Vector2(12, 336) + _salutation.size = Vector2(250, 46) + for id: String in NetworkMailProtocol.SALUTATIONS: + _salutation.add_item(SALUTATION_LABELS[id]) + _salutation.set_item_metadata(_salutation.item_count - 1, id) + page.add_child(_salutation) + _signature = Label.new() + _signature.position = Vector2(274, 342) + _signature.size = Vector2(360, 36) + page.add_child(_signature) + _attachment_kind = OptionButton.new() + _attachment_kind.position = Vector2(688, 48) + _attachment_kind.size = Vector2(280, 46) + for label: String in ["No gift", "Fish coin", "Fish", "Consumable"]: + _attachment_kind.add_item(label) + _attachment_kind.item_selected.connect(_refresh_attachment_choices) + page.add_child(_attachment_kind) + _attachment_choice = OptionButton.new() + _attachment_choice.position = Vector2(688, 106) + _attachment_choice.size = Vector2(280, 46) + _attachment_choice.item_selected.connect( + func(_index: int) -> void: + _update_attachment_amount_limit() + _update_attachment_summary() + ) + page.add_child(_attachment_choice) + _attachment_amount = SpinBox.new() + _attachment_amount.position = Vector2(688, 164) + _attachment_amount.size = Vector2(180, 46) + _attachment_amount.min_value = 1 + _attachment_amount.max_value = 999 + _attachment_amount.value = 1 + _attachment_amount.value_changed.connect( + func(_value: float) -> void: _update_attachment_summary() + ) + page.add_child(_attachment_amount) + _attachment_summary = Label.new() + _attachment_summary.position = Vector2(688, 224) + _attachment_summary.size = Vector2(280, 100) + _attachment_summary.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + page.add_child(_attachment_summary) + var cancel := Button.new() + cancel.text = "cancel" + cancel.position = Vector2(688, 342) + cancel.size = Vector2(126, 48) + cancel.pressed.connect(_show_inbox) + page.add_child(cancel) + _send_button = Button.new() + _send_button.text = "send letter" + _send_button.position = Vector2(826, 342) + _send_button.size = Vector2(142, 48) + _send_button.pressed.connect(_send) + page.add_child(_send_button) + _recipient.item_selected.connect(func(_i: int) -> void: _update_send_state()) + _refresh_attachment_choices(0) + return page + + +func _build_letter() -> Control: + var page := Control.new() + _letter_text = Label.new() + _letter_text.position = Vector2(26, 20) + _letter_text.size = Vector2(680, 350) + _letter_text.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _letter_text.vertical_alignment = VERTICAL_ALIGNMENT_TOP + page.add_child(_letter_text) + _letter_gift = Label.new() + _letter_gift.position = Vector2(730, 46) + _letter_gift.size = Vector2(230, 180) + _letter_gift.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + page.add_child(_letter_gift) + _accept = Button.new() + _accept.text = "accept gift" + _accept.position = Vector2(730, 250) + _accept.size = Vector2(230, 48) + _accept.pressed.connect(func() -> void: + _service.accept_gift(_current_mail_id) + ) + page.add_child(_accept) + _decline = Button.new() + _decline.text = "decline gift" + _decline.position = Vector2(730, 308) + _decline.size = Vector2(230, 48) + _decline.pressed.connect(func() -> void: + _service.decline_gift(_current_mail_id) + ) + page.add_child(_decline) + var close := Button.new() + close.text = "close" + close.position = Vector2(26, 370) + close.size = Vector2(150, 48) + close.pressed.connect(_show_inbox) + page.add_child(close) + return page + + +func _show_inbox() -> void: + _inbox.show() + _compose.hide() + _letter.hide() + _current_mail_id = "" + _refresh_inbox() + + +func _show_compose() -> void: + _inbox.hide() + _compose.show() + _letter.hide() + _body.clear() + _signature.text = _service.get_local_display_name() + _status.text = "" + _refresh_recipients() + _refresh_attachment_choices(_attachment_kind.selected) + _update_send_state() + _greeting.grab_focus() + + +func _open_letter(mail_id: String) -> void: + var letter := _service.get_letter(mail_id) + if letter.is_empty(): + return + _current_mail_id = mail_id + _service.mark_read(mail_id) + _inbox.hide() + _compose.hide() + _letter.show() + _letter_text.text = "%s %s,\n\n%s\n\n%s,\n%s" % [ + GREETING_LABELS.get(letter["greeting_id"], "Dear"), + letter.get("recipient_display_name", "you"), + letter["body"], + SALUTATION_LABELS.get(letter["salutation_id"], "From"), + letter["sender_display_name"], + ] + _letter_gift.text = _attachment_state_text(letter) + var pending := ( + int(letter["attachment"].get("type", 0)) != 0 + and int(letter["state"]) in [ + NetworkMailProtocol.State.SENT_UNREAD, + NetworkMailProtocol.State.READ, + ] + and _service.is_local_recipient(letter) + ) + _accept.visible = pending + _decline.visible = pending + if pending: + _accept.grab_focus() + + +func _refresh_inbox() -> void: + if _service == null or _inbox_list == null: + return + for child: Node in _inbox_list.get_children(): + child.queue_free() + var letters := _service.get_local_letters() + if letters.is_empty(): + var empty := Label.new() + empty.text = "No letters yet." + empty.custom_minimum_size = Vector2(930, 80) + empty.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _inbox_list.add_child(empty) + return + for letter: Dictionary in letters: + var button := Button.new() + var first_line: String = str(letter["body"]).split("\n", false)[0] + var unread := int(letter["state"]) == NetworkMailProtocol.State.SENT_UNREAD + var gift := int(letter["attachment"].get("type", 0)) != 0 + button.text = "%s%s — %s%s" % [ + "new · " if unread else "", + letter["sender_display_name"], + first_line.left(72), + " · gift enclosed" if gift else "", + ] + button.custom_minimum_size = Vector2(930, 54) + button.alignment = HORIZONTAL_ALIGNMENT_LEFT + button.pressed.connect(_open_letter.bind(str(letter["mail_id"]))) + _inbox_list.add_child(button) + + +func _refresh_recipients() -> void: + if _service == null or _recipient == null: + return + var selected_peer := ( + int(_recipient.get_selected_metadata()) + if _recipient.selected >= 0 + and _recipient.get_selected_metadata() != null else 0 + ) + _recipient.clear() + _recipient.add_item("select a player") + _recipient.set_item_metadata(0, 0) + var retained := false + for choice: Dictionary in _service.get_recipient_choices(): + _recipient.add_item(choice["label"]) + _recipient.set_item_metadata( + _recipient.item_count - 1, choice["peer_id"] + ) + if int(choice["peer_id"]) == selected_peer: + _recipient.select(_recipient.item_count - 1) + retained = true + if selected_peer > 0 and not retained: + _recipient.select(0) + if _status != null: + _status.text = "That player is no longer connected." + _update_send_state() + + +func _refresh_attachment_choices(_index: int) -> void: + if _attachment_choice == null: + return + _attachment_choice.clear() + _attachment_amount.visible = _attachment_kind.selected in [1, 3] + match _attachment_kind.selected: + 0: + _attachment_choice.add_item("No attachment") + 1: + _attachment_choice.add_item( + "available: %d fish coin" + % _reservations.get_available_fish_coin() + ) + _attachment_amount.max_value = maxi( + _reservations.get_available_fish_coin(), 1 + ) + 2: + for fish_catch: FishCatch in _inventory.get_all_catches(): + if ( + not fish_catch.is_favorited + and not _reservations.is_fish_reserved(fish_catch.catch_id) + ): + _attachment_choice.add_item( + "%s — %.1f lb" % [ + fish_catch.fish.display_name, fish_catch.weight_lb, + ] + ) + _attachment_choice.set_item_metadata( + _attachment_choice.item_count - 1, str(fish_catch.catch_id) + ) + 3: + for item: ItemData in _catalog.get_valid_items(): + var available := _reservations.get_available_item_quantity( + item.item_id + ) + if item.category == ItemData.Category.CONSUMABLE and available > 0: + _attachment_choice.add_item( + "%s ×%d" % [item.display_name, available] + ) + _attachment_choice.set_item_metadata( + _attachment_choice.item_count - 1, str(item.item_id) + ) + _update_attachment_amount_limit() + _update_attachment_summary() + + +func _update_attachment_amount_limit() -> void: + if _attachment_kind.selected == 3 and _attachment_choice.selected >= 0: + var item_id := StringName(str(_attachment_choice.get_selected_metadata())) + _attachment_amount.max_value = maxi( + _reservations.get_available_item_quantity(item_id), 1 + ) + + +func _make_attachment() -> Dictionary: + match _attachment_kind.selected: + 1: + return { + "type": PlayerAssetReservationService.AttachmentType.FISH_COIN, + "amount": int(_attachment_amount.value), + } + 2: + if _attachment_choice.selected < 0: + return {} + var catch_id := StringName(str(_attachment_choice.get_selected_metadata())) + var fish_catch := _inventory.get_catch_by_id(catch_id) + if fish_catch == null: + return {} + return { + "type": PlayerAssetReservationService.AttachmentType.FISH, + "catch_id": str(catch_id), + "catch": fish_catch.to_network_dict(), + } + 3: + if _attachment_choice.selected < 0: + return {} + return { + "type": PlayerAssetReservationService.AttachmentType.CONSUMABLE, + "item_id": str(_attachment_choice.get_selected_metadata()), + "quantity": int(_attachment_amount.value), + } + return {"type": PlayerAssetReservationService.AttachmentType.NONE} + + +func _update_attachment_summary() -> void: + if _attachment_summary == null: + return + var attachment := _make_attachment() + _attachment_summary.text = _attachment_text(attachment) + _update_send_state() + + +func _update_send_state() -> void: + if _send_button == null: + return + var recipient_id := ( + int(_recipient.get_selected_metadata()) + if _recipient.selected >= 0 and _recipient.get_selected_metadata() != null + else 0 + ) + var attachment := _make_attachment() + _send_button.disabled = ( + recipient_id <= 0 + or NetworkMailProtocol.sanitize_body(_body.text).is_empty() + or attachment.is_empty() + or not _reservations.validate_attachment(attachment) + or not _attachment_is_available(attachment) + ) + + +func _attachment_is_available(attachment: Dictionary) -> bool: + match int(attachment.get("type", 0)): + PlayerAssetReservationService.AttachmentType.NONE: + return true + PlayerAssetReservationService.AttachmentType.FISH_COIN: + return ( + int(attachment["amount"]) + <= _reservations.get_available_fish_coin() + ) + PlayerAssetReservationService.AttachmentType.FISH: + return not _reservations.is_fish_reserved( + StringName(str(attachment["catch_id"])) + ) + PlayerAssetReservationService.AttachmentType.CONSUMABLE: + return ( + int(attachment["quantity"]) + <= _reservations.get_available_item_quantity( + StringName(str(attachment["item_id"])) + ) + ) + return false + + +func _send() -> void: + var recipient_id := int(_recipient.get_selected_metadata()) + var greeting_id := str(_greeting.get_selected_metadata()) + var salutation_id := str(_salutation.get_selected_metadata()) + if _service.send_letter( + recipient_id, greeting_id, _body.text, salutation_id, _make_attachment() + ): + _send_button.disabled = true + _status.text = "Sending letter…" + + +func _on_operation_finished(success: bool, message: String) -> void: + _status.text = message + if success and _compose.visible: + _show_inbox() + elif _letter.visible and not _current_mail_id.is_empty(): + _open_letter(_current_mail_id) + else: + _update_send_state() + + +func _attachment_state_text(letter: Dictionary) -> String: + var attachment: Dictionary = letter["attachment"] + if int(attachment.get("type", 0)) == 0: + return "" + match int(letter["state"]): + NetworkMailProtocol.State.ACCEPTED: + return "Gift accepted.\n\n%s" % _attachment_text(attachment) + NetworkMailProtocol.State.DECLINED: + return "Gift declined." + NetworkMailProtocol.State.ATTACHMENT_RECALLED, NetworkMailProtocol.State.CANCELLED: + return "Gift returned to sender." + NetworkMailProtocol.State.ACCEPTANCE_PENDING: + return "Transferring gift…" + return "Gift enclosed\n\n%s" % _attachment_text(attachment) + + +func _attachment_text(attachment: Dictionary) -> String: + match int(attachment.get("type", 0)): + PlayerAssetReservationService.AttachmentType.NONE: + return "No gift attached." + PlayerAssetReservationService.AttachmentType.FISH_COIN: + return "%d fish coin" % int(attachment.get("amount", 0)) + PlayerAssetReservationService.AttachmentType.FISH: + var fish_catch := _inventory.get_catch_by_id( + StringName(str(attachment.get("catch_id", ""))) + ) + if fish_catch != null: + return "%s — %.1f lb" % [ + fish_catch.fish.display_name, fish_catch.weight_lb, + ] + var data: Dictionary = attachment.get("catch", {}) + return "%s — %.1f lb" % [ + str(data.get("fish_id", "fish")).capitalize(), + float(data.get("weight_lb", 0.0)), + ] + PlayerAssetReservationService.AttachmentType.CONSUMABLE: + var item := _catalog.get_item_by_id( + StringName(str(attachment.get("item_id", ""))) + ) + return "%s ×%d" % [ + item.display_name if item != null else "Consumable", + int(attachment.get("quantity", 0)), + ] + return "Invalid gift." + + +func _focus_first() -> void: + var buttons := _inbox_list.find_children("*", "Button", true, false) + if not buttons.is_empty(): + (buttons.front() as Button).grab_focus() diff --git a/ui/mail_page.gd.uid b/ui/mail_page.gd.uid new file mode 100644 index 0000000..d6de35d --- /dev/null +++ b/ui/mail_page.gd.uid @@ -0,0 +1 @@ +uid://urjasxokrp8j diff --git a/ui/player_menu.gd b/ui/player_menu.gd index 8669aa3..30f664a 100644 --- a/ui/player_menu.gd +++ b/ui/player_menu.gd @@ -72,7 +72,7 @@ const DESKTOP_REFERENCE_SIZE := Vector2(1280.0, 720.0) const COMPACT_REFERENCE_SIZE := Vector2(640.0, 480.0) const COMPACT_HEIGHT_THRESHOLD: float = 560.0 const NAVIGATION_PRESENTATION_SCALE: float = 0.60 -const NAVIGATION_CANONICAL_POSITION := Vector2(463.0, 44.0) +const NAVIGATION_CANONICAL_POSITION := Vector2(424.0, 44.0) const NAVIGATION_SELECTED_SCALE: float = 1.02 const COOLER_RARITY_COMMON := Color("e8eef0") const COOLER_RARITY_UNCOMMON := Color("64c87c") @@ -86,6 +86,7 @@ enum Section { COOLER, BAG, LOGBOOK, + MAIL, } enum SortMode { @@ -154,6 +155,7 @@ enum CloseReason { @onready var _bag_sprite_detail_name: Label = %BagSpriteDetailName @onready var _bag_sprite_detail_data: Label = %BagSpriteDetailData @onready var _logbook_page: Control = %LogbookPage +@onready var _mail_page: MailPage = %MailPage @onready var _book_backing: PanelContainer = %BookBacking @onready var _book_spread: BoxContainer = %BookSpread @onready var _left_page: PanelContainer = %LeftPage @@ -170,6 +172,8 @@ enum CloseReason { @onready var _inventory_tab: BubbleButtonType = %InventoryTab @onready var _bag_tab: BubbleButtonType = %BagTab @onready var _logbook_tab: BubbleButtonType = %LogbookTab +@onready var _mail_tab: BubbleButtonType = %MailTab +@onready var _mail_unread_badge: Label = %MailUnreadBadge @onready var _close_button: BubbleButtonType = %CloseButton @onready var _content_shell: BubbleContentShellType = %MenuPanel @onready var _content_stage: Control = %Content @@ -228,6 +232,7 @@ var _wallet: PlayerWalletType var _sale_service: FishSaleServiceType var _network_session: NetworkSessionType var _network_sale_service: NetworkSaleService +var _network_mail_service: NetworkMailService var _default_buyer: FishBuyerProfileType var _catalog: FishPoolType var _fishing_spot: FishingSpotType @@ -262,6 +267,7 @@ var _content_rest_position: Vector2 = Vector2.ZERO var _cooler_rest_position: Vector2 = Vector2.ZERO var _bag_rest_position: Vector2 = Vector2.ZERO var _logbook_rest_position: Vector2 = Vector2.ZERO +var _mail_rest_position: Vector2 = Vector2.ZERO var _page_outgoing_root: Control var _page_incoming_root: Control var _page_hosts_shared: bool = false @@ -287,6 +293,7 @@ func _ready() -> void: _logbook_tab.pressed.connect( _show_section.bind(Section.LOGBOOK) ) + _mail_tab.pressed.connect(_show_section.bind(Section.MAIL)) _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) @@ -312,6 +319,7 @@ func _ready() -> void: _inventory_tab, _bag_tab, _logbook_tab, + _mail_tab, _close_button, ]) _configure_navigation_focus() @@ -358,6 +366,8 @@ func setup( cooler_capacity: PlayerCoolerCapacityType, network_session: NetworkSessionType, network_sale_service: NetworkSaleService, + network_mail_service: NetworkMailService, + reservations: PlayerAssetReservationService, ) -> void: _player = player _inventory = inventory @@ -373,6 +383,14 @@ func setup( _cooler_capacity = cooler_capacity _network_session = network_session _network_sale_service = network_sale_service + _network_mail_service = network_mail_service + _mail_page.setup( + network_mail_service, reservations, inventory, wallet, bag, item_catalog + ) + _network_mail_service.unread_count_changed.connect( + _on_mail_unread_count_changed + ) + _on_mail_unread_count_changed(_network_mail_service.get_unread_count()) if ( _network_sale_service != null and not _network_sale_service.local_sale_pending.is_connected( @@ -428,6 +446,8 @@ func consume_escape() -> bool: return true if _sale_confirmation.visible: _close_sale_confirmation() + elif _current_section == Section.MAIL and _mail_page.consume_escape(): + pass else: close_menu() return true @@ -563,6 +583,7 @@ func _show_section_immediate(section: Section) -> void: _cooler_page.visible = section == Section.COOLER _bag_page.visible = section == Section.BAG _logbook_page.visible = section == Section.LOGBOOK + _mail_page.visible = section == Section.MAIL _content_shell.visible = false _inventory_section.visible = false _bag_section.visible = false @@ -581,9 +602,14 @@ func _show_section_immediate(section: Section) -> void: _update_bag_detail() if section != Section.LOGBOOK: _cancel_logbook_page_transition(true) + if section == Section.MAIL: + _mail_page.activate() + else: + _mail_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 _update_navigation_selection() _configure_active_page_focus() @@ -593,8 +619,10 @@ func _focus_current_section() -> void: _inventory_tab.grab_focus() elif _current_section == Section.BAG: _bag_tab.grab_focus() - else: + elif _current_section == Section.LOGBOOK: _logbook_tab.grab_focus() + else: + _mail_tab.grab_focus() func _process(delta: float) -> void: @@ -621,6 +649,7 @@ func _configure_navigation_focus() -> void: _inventory_tab, _bag_tab, _logbook_tab, + _mail_tab, _close_button, ] for index: int in navigation.size(): @@ -676,6 +705,8 @@ func _configure_active_page_focus() -> void: _configure_bag_item_focus() Section.LOGBOOK: _configure_logbook_focus() + Section.MAIL: + _mail_page.activate() func _apply_cooler_control_styles() -> void: @@ -768,6 +799,7 @@ func _apply_navigation_styles() -> void: _inventory_tab, _bag_tab, _logbook_tab, + _mail_tab, _close_button, ]: bubble.add_theme_stylebox_override("normal", normal) @@ -797,6 +829,7 @@ func _apply_navigation_selection_presentation() -> void: _inventory_tab, _bag_tab, _logbook_tab, + _mail_tab, ]: if not bubble.button_pressed: continue @@ -895,10 +928,16 @@ func _update_navigation_selection() -> void: _set_navigation_target(_current_section) +func _on_mail_unread_count_changed(count: int) -> void: + _mail_unread_badge.visible = count > 0 + _mail_unread_badge.text = "99+" if count > 99 else str(count) + + func _set_navigation_target(section: Section) -> void: _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 func _update_shell_layout() -> void: @@ -915,7 +954,7 @@ func _update_shell_layout() -> void: _content_shell.size = Vector2(612.0, 286.0) if compact else Vector2(1196.0, 478.0) _presentation_rest_position = _content_shell.position var navigation_size := ( - Vector2(620.0, 75.0) if compact else Vector2(620.0, 100.0) + Vector2(720.0, 75.0) if compact else Vector2(720.0, 100.0) ) _navigation_cluster.position = NAVIGATION_CANONICAL_POSITION _navigation_cluster.size = navigation_size @@ -1084,6 +1123,9 @@ func _update_shell_layout() -> void: _logbook_page.size = reference_size _logbook_page.position = Vector2.ZERO _logbook_rest_position = Vector2.ZERO + _mail_page.size = reference_size + _mail_page.position = Vector2.ZERO + _mail_rest_position = Vector2.ZERO _book_backing.position = ( Vector2(14.0, 164.0) if compact else Vector2(58.0, 128.0) ) @@ -1144,9 +1186,11 @@ func _update_shell_layout() -> void: _cooler_page.position = _cooler_rest_position _bag_page.position = _bag_rest_position _logbook_page.position = _logbook_rest_position + _mail_page.position = _mail_rest_position _cooler_page.modulate.a = 1.0 _bag_page.modulate.a = 1.0 _logbook_page.modulate.a = 1.0 + _mail_page.modulate.a = 1.0 if not _page_transitioning: _content_stage.position = _content_rest_position _layout_cooler_fish(false) @@ -1229,6 +1273,7 @@ func _begin_menu_entry() -> void: _cooler_page.position = _cooler_rest_position + Vector2.DOWN * start_offset _bag_page.position = _bag_rest_position + Vector2.DOWN * start_offset _logbook_page.position = _logbook_rest_position + Vector2.DOWN * start_offset + _mail_page.position = _mail_rest_position + Vector2.DOWN * start_offset var navigation_rest: Vector2 = _navigation_cluster.position _navigation_cluster.position = navigation_rest + Vector2.DOWN * start_offset _presentation_tween = create_tween().set_parallel(true) @@ -1256,6 +1301,12 @@ func _begin_menu_entry() -> void: _logbook_rest_position, MENU_ENTER_DURATION ).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT) + _presentation_tween.tween_property( + _mail_page, + "position", + _mail_rest_position, + MENU_ENTER_DURATION + ).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT) _presentation_tween.tween_property( _navigation_cluster, "position", @@ -1316,6 +1367,12 @@ func _begin_menu_exit(reason: CloseReason, restore_controls: bool) -> void: -_logbook_page.size.y - TRANSITION_SAFE_MARGIN, MENU_EXIT_DURATION ).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT) + _presentation_tween.tween_property( + _mail_page, + "position:y", + -_mail_page.size.y - TRANSITION_SAFE_MARGIN, + MENU_EXIT_DURATION + ).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT) _presentation_tween.tween_property( _navigation_cluster, "position:y", @@ -1377,8 +1434,10 @@ func _get_section_root(section: Section) -> Control: return _cooler_page Section.BAG: return _bag_page - _: + Section.LOGBOOK: return _logbook_page + _: + return _mail_page func _get_section_rest_position(section: Section) -> Vector2: @@ -1387,8 +1446,10 @@ func _get_section_rest_position(section: Section) -> Vector2: return _cooler_rest_position Section.BAG: return _bag_rest_position - _: + Section.LOGBOOK: return _logbook_rest_position + _: + return _mail_rest_position func _begin_page_transition(section: Section) -> void: @@ -1468,6 +1529,7 @@ func _set_shell_interactive(interactive: bool) -> void: _inventory_tab, _bag_tab, _logbook_tab, + _mail_tab, _close_button, ]: bubble.focus_mode = Control.FOCUS_ALL if interactive else Control.FOCUS_NONE @@ -1499,6 +1561,9 @@ func _set_content_interactive(interactive: bool) -> void: if interactive and _current_section == Section.LOGBOOK else Control.MOUSE_FILTER_IGNORE ) + _mail_page.set_interactive( + interactive and _current_section == Section.MAIL + ) var cooler_interactive: bool = ( interactive and _current_section == Section.COOLER ) @@ -1569,7 +1634,9 @@ func _cancel_page_tween() -> void: func _reset_page_transition_visuals() -> void: - for section: Section in [Section.COOLER, Section.BAG, Section.LOGBOOK]: + for section: Section in [ + Section.COOLER, Section.BAG, Section.LOGBOOK, Section.MAIL, + ]: var page: Control = _get_section_root(section) page.position = _get_section_rest_position(section) page.modulate.a = 1.0 diff --git a/ui/player_menu.tscn b/ui/player_menu.tscn index 789850d..48e1e62 100644 --- a/ui/player_menu.tscn +++ b/ui/player_menu.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=19 format=3] +[gd_scene load_steps=20 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"] @@ -17,6 +17,7 @@ [ext_resource type="Texture2D" path="res://ui/assets/title/bubbles/bubble1.png" id="15_bubble1"] [ext_resource type="Texture2D" path="res://ui/assets/title/bubbles/bubble2.png" id="16_bubble2"] [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"] [sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_collection"] @@ -574,6 +575,14 @@ offset_right = 1280.0 offset_bottom = 720.0 mouse_filter = 1 +[node name="MailPage" type="Control" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot"] +unique_name_in_owner = true +layout_mode = 0 +offset_right = 1280.0 +offset_bottom = 720.0 +mouse_filter = 1 +script = ExtResource("18_mail_page") + [node name="BookBacking" type="PanelContainer" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/LogbookPage"] unique_name_in_owner = true layout_mode = 0 @@ -1197,15 +1206,15 @@ custom_minimum_size = Vector2(170, 64) [node name="NavigationCluster" type="Control" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot"] unique_name_in_owner = true layout_mode = 0 -offset_left = 463.0 +offset_left = 390.0 offset_top = 44.0 -offset_right = 1083.0 +offset_right = 1110.0 offset_bottom = 144.0 mouse_filter = 1 script = ExtResource("5_cluster") profile = ExtResource("4_profile") -desktop_reference_size = Vector2(620, 100) -compact_reference_size = Vector2(620, 100) +desktop_reference_size = Vector2(720, 100) +compact_reference_size = Vector2(720, 100) [node name="InventoryTab" type="Button" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/NavigationCluster"] unique_name_in_owner = true @@ -1267,6 +1276,42 @@ motion_phase = 2.85 deformation_amplitude = 0.012 deformation_period = 6.7 +[node name="MailTab" type="Button" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/NavigationCluster"] +unique_name_in_owner = true +layout_mode = 0 +toggle_mode = true +text = "mail" +script = ExtResource("6_bubble") +profile = ExtResource("4_profile") +neutral_size = Vector2(124, 120) +desktop_anchor = Vector2(500, 60) +compact_anchor = Vector2(501, 73.3333) +compact_minimum_size = Vector2(84, 82) +minimum_font_size = 25 +maximum_font_size = 25 +horizontal_amplitude = 1.1 +vertical_amplitude = 2.2 +motion_period = 5.5 +motion_phase = 3.55 +deformation_amplitude = 0.011 +deformation_period = 6.3 + +[node name="MailUnreadBadge" type="Label" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/NavigationCluster/MailTab"] +unique_name_in_owner = true +layout_mode = 0 +offset_left = 86.0 +offset_top = 2.0 +offset_right = 120.0 +offset_bottom = 34.0 +mouse_filter = 2 +theme_override_colors/font_color = Color(0.12, 0.08, 0.04, 1) +theme_override_colors/font_outline_color = Color(1, 0.88, 0.34, 1) +theme_override_constants/outline_size = 7 +theme_override_font_sizes/font_size = 18 +text = "1" +horizontal_alignment = 1 +vertical_alignment = 1 + [node name="CloseButton" type="Button" parent="ResponsivePlayerMenuStage/PlayerMenuPresentationScaleRoot/NavigationCluster"] unique_name_in_owner = true layout_mode = 0 @@ -1274,8 +1319,8 @@ text = "close" script = ExtResource("6_bubble") profile = ExtResource("4_profile") neutral_size = Vector2(96, 94) -desktop_anchor = Vector2(489, 57) -compact_anchor = Vector2(512.667, 73.3333) +desktop_anchor = Vector2(626, 57) +compact_anchor = Vector2(616, 73.3333) compact_minimum_size = Vector2(76, 74) minimum_font_size = 23 maximum_font_size = 23