2026-07-29 20:10:44 -04:00
|
|
|
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] = {}
|
2026-07-29 23:10:51 -04:00
|
|
|
var _relationship_policy: NetworkPlayerListService
|
2026-07-30 14:36:48 -04:00
|
|
|
var _archived_local_letters: Dictionary[String, bool] = {}
|
|
|
|
|
var _deleted_local_letters: Dictionary[String, bool] = {}
|
2026-07-29 20:10:44 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 23:10:51 -04:00
|
|
|
func set_relationship_policy(policy: NetworkPlayerListService) -> void:
|
|
|
|
|
_relationship_policy = policy
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func refresh_relationship_filters() -> void:
|
|
|
|
|
_emit_mailbox()
|
|
|
|
|
peers_changed.emit()
|
|
|
|
|
|
|
|
|
|
|
2026-07-30 14:36:48 -04:00
|
|
|
func get_local_letters(archived: bool = false) -> Array[Dictionary]:
|
2026-07-29 20:10:44 -04:00
|
|
|
var values: Array[Dictionary] = []
|
|
|
|
|
for letter: Dictionary in _local_letters.values():
|
2026-07-29 23:10:51 -04:00
|
|
|
if (
|
2026-07-30 14:36:48 -04:00
|
|
|
_session.get_local_peer_id() in [
|
|
|
|
|
int(letter["sender_peer_id"]),
|
|
|
|
|
int(letter["recipient_peer_id"]),
|
|
|
|
|
]
|
|
|
|
|
and not _deleted_local_letters.has(str(letter["mail_id"]))
|
|
|
|
|
and _archived_local_letters.has(str(letter["mail_id"])) == archived
|
2026-07-29 23:10:51 -04:00
|
|
|
and not _letter_is_locally_blocked(letter)
|
|
|
|
|
):
|
2026-07-29 20:10:44 -04:00
|
|
|
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:
|
2026-07-30 14:36:48 -04:00
|
|
|
if _deleted_local_letters.has(mail_id):
|
|
|
|
|
return {}
|
2026-07-29 20:10:44 -04:00
|
|
|
return _local_letters.get(mail_id, {}).duplicate(true)
|
|
|
|
|
|
|
|
|
|
|
2026-07-30 14:36:48 -04:00
|
|
|
func archive_local_letter(mail_id: String, archived: bool = true) -> bool:
|
|
|
|
|
if not _local_letters.has(mail_id) or _deleted_local_letters.has(mail_id):
|
|
|
|
|
return false
|
|
|
|
|
if archived:
|
|
|
|
|
_archived_local_letters[mail_id] = true
|
|
|
|
|
else:
|
|
|
|
|
_archived_local_letters.erase(mail_id)
|
|
|
|
|
_emit_mailbox()
|
|
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func delete_local_letter(mail_id: String) -> bool:
|
|
|
|
|
var letter: Dictionary = _local_letters.get(mail_id, {})
|
|
|
|
|
if letter.is_empty() or _incoming_gift_is_unresolved(letter):
|
|
|
|
|
return false
|
|
|
|
|
_deleted_local_letters[mail_id] = true
|
|
|
|
|
_archived_local_letters.erase(mail_id)
|
|
|
|
|
_emit_mailbox()
|
|
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func is_letter_archived(mail_id: String) -> bool:
|
|
|
|
|
return _archived_local_letters.has(mail_id)
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 20:10:44 -04:00
|
|
|
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
|
2026-07-30 14:36:48 -04:00
|
|
|
and not _archived_local_letters.has(str(letter["mail_id"]))
|
|
|
|
|
and not _deleted_local_letters.has(str(letter["mail_id"]))
|
2026-07-29 23:10:51 -04:00
|
|
|
and not _letter_is_locally_blocked(letter)
|
2026-07-29 20:10:44 -04:00
|
|
|
):
|
|
|
|
|
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)
|
2026-07-29 23:10:51 -04:00
|
|
|
if record != null and (
|
|
|
|
|
_relationship_policy == null
|
|
|
|
|
or not _relationship_policy.is_locally_blocked(
|
|
|
|
|
record.identity_fingerprint
|
|
|
|
|
)
|
|
|
|
|
):
|
2026-07-29 22:18:14 -04:00
|
|
|
choices.append({
|
|
|
|
|
"peer_id": peer_id,
|
|
|
|
|
"name": record.display_name,
|
|
|
|
|
"fingerprint": record.identity_fingerprint,
|
|
|
|
|
})
|
2026-07-29 20:10:44 -04:00
|
|
|
choices.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
|
|
|
|
return str(a["name"]).naturalnocasecmp_to(str(b["name"])) < 0
|
|
|
|
|
)
|
2026-07-29 22:18:14 -04:00
|
|
|
var counts: Dictionary[String, int] = {}
|
|
|
|
|
for choice: Dictionary in choices:
|
|
|
|
|
var normalized := str(choice["name"]).to_lower()
|
|
|
|
|
counts[normalized] = int(counts.get(normalized, 0)) + 1
|
2026-07-29 20:10:44 -04:00
|
|
|
for choice: Dictionary in choices:
|
2026-07-30 00:54:53 -04:00
|
|
|
var display_name: String = choice["name"]
|
|
|
|
|
if int(counts.get(display_name.to_lower(), 0)) > 1:
|
2026-07-29 22:18:14 -04:00
|
|
|
choice["label"] = "%s · %s" % [
|
2026-07-30 00:54:53 -04:00
|
|
|
display_name,
|
2026-07-29 22:18:14 -04:00
|
|
|
NetworkIdentityCrypto.compact_suffix(choice["fingerprint"]),
|
|
|
|
|
]
|
2026-07-29 20:10:44 -04:00
|
|
|
else:
|
2026-07-30 00:54:53 -04:00
|
|
|
choice["label"] = display_name
|
2026-07-29 20:10:44 -04:00
|
|
|
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)
|
2026-07-29 23:10:51 -04:00
|
|
|
or (
|
|
|
|
|
_relationship_policy != null
|
|
|
|
|
and _relationship_policy.is_locally_blocked(
|
|
|
|
|
_session.get_peer_record(recipient_peer_id).identity_fingerprint
|
|
|
|
|
)
|
|
|
|
|
)
|
2026-07-29 20:10:44 -04:00
|
|
|
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),
|
2026-07-29 22:18:14 -04:00
|
|
|
"sender_fingerprint": _session.get_local_identity_fingerprint(),
|
|
|
|
|
"recipient_fingerprint": (
|
|
|
|
|
_session.get_peer_record(recipient_peer_id).identity_fingerprint
|
|
|
|
|
),
|
2026-07-29 20:10:44 -04:00
|
|
|
}
|
2026-07-29 22:18:14 -04:00
|
|
|
request["sender_signature"] = _session.sign_local_action(
|
|
|
|
|
"mail_send", NetworkMailProtocol.mail_signature_fields(request)
|
|
|
|
|
)
|
2026-07-29 20:10:44 -04:00
|
|
|
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."
|
2026-07-29 22:18:14 -04:00
|
|
|
var sender_record := _session.get_peer_record(sender)
|
|
|
|
|
var recipient_record := _session.get_peer_record(recipient)
|
|
|
|
|
if error.is_empty() and (
|
|
|
|
|
sender_record == null
|
|
|
|
|
or recipient_record == null
|
|
|
|
|
or sender_record.identity_fingerprint
|
|
|
|
|
!= str(data.get("sender_fingerprint", ""))
|
|
|
|
|
or recipient_record.identity_fingerprint
|
|
|
|
|
!= str(data.get("recipient_fingerprint", ""))
|
|
|
|
|
or not _session.verify_peer_action(
|
|
|
|
|
sender,
|
|
|
|
|
"mail_send",
|
|
|
|
|
NetworkMailProtocol.mail_signature_fields(data),
|
|
|
|
|
data.get("sender_signature", PackedByteArray()),
|
|
|
|
|
)
|
|
|
|
|
):
|
|
|
|
|
error = "Letter identity could not be verified."
|
2026-07-30 14:36:48 -04:00
|
|
|
var silently_dropped := (
|
2026-07-29 23:10:51 -04:00
|
|
|
error.is_empty()
|
|
|
|
|
and _relationship_policy != null
|
|
|
|
|
and _relationship_policy.pair_is_blocked(
|
|
|
|
|
sender_record.identity_fingerprint,
|
|
|
|
|
recipient_record.identity_fingerprint,
|
|
|
|
|
)
|
2026-07-30 14:36:48 -04:00
|
|
|
)
|
|
|
|
|
if (
|
|
|
|
|
error.is_empty()
|
|
|
|
|
and not silently_dropped
|
|
|
|
|
and _letters.size() >= NetworkMailProtocol.MAX_SESSION_LETTERS
|
2026-07-29 23:10:51 -04:00
|
|
|
):
|
2026-07-29 20:10:44 -04:00
|
|
|
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()
|
2026-07-30 14:36:48 -04:00
|
|
|
and not silently_dropped
|
2026-07-29 20:10:44 -04:00
|
|
|
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", "")),
|
2026-07-30 14:36:48 -04:00
|
|
|
"silent_drop": silently_dropped,
|
2026-07-29 20:10:44 -04:00
|
|
|
}
|
2026-07-30 14:36:48 -04:00
|
|
|
if error.is_empty() and silently_dropped:
|
|
|
|
|
_sequence += 1
|
|
|
|
|
var sender_copy := _build_letter(
|
|
|
|
|
data, sender, recipient, sender_record, recipient_record, _sequence
|
|
|
|
|
)
|
|
|
|
|
result["mail"] = sender_copy
|
|
|
|
|
_deliver_private_letter(sender, sender_copy)
|
|
|
|
|
_request_release(sender, str(data.get("reservation_id", "")))
|
|
|
|
|
elif error.is_empty():
|
2026-07-29 20:10:44 -04:00
|
|
|
_sequence += 1
|
2026-07-30 14:36:48 -04:00
|
|
|
var letter := _build_letter(
|
|
|
|
|
data, sender, recipient, sender_record, recipient_record, _sequence
|
|
|
|
|
)
|
2026-07-29 20:10:44 -04:00
|
|
|
_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)
|
|
|
|
|
|
|
|
|
|
|
2026-07-30 14:36:48 -04:00
|
|
|
func _build_letter(
|
|
|
|
|
data: Dictionary,
|
|
|
|
|
sender: int,
|
|
|
|
|
recipient: int,
|
|
|
|
|
sender_record: PeerRegistry.PeerRecord,
|
|
|
|
|
recipient_record: PeerRegistry.PeerRecord,
|
|
|
|
|
sequence: int,
|
|
|
|
|
) -> Dictionary:
|
|
|
|
|
return {
|
|
|
|
|
"mail_id": _new_id("mail"),
|
|
|
|
|
"session_id": _session.get_session_id(),
|
|
|
|
|
"sequence": sequence,
|
|
|
|
|
"sender_peer_id": sender,
|
|
|
|
|
"sender_display_name": sender_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": (data.get("attachment", {}) as Dictionary).duplicate(true),
|
|
|
|
|
"state": NetworkMailProtocol.State.SENT_UNREAD,
|
|
|
|
|
"request_id": str(data["request_id"]),
|
|
|
|
|
"sender_fingerprint": data["sender_fingerprint"],
|
|
|
|
|
"recipient_fingerprint": data["recipient_fingerprint"],
|
|
|
|
|
"sender_signature": data["sender_signature"],
|
|
|
|
|
"sender_public_key": sender_record.identity_public_key,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 20:10:44 -04:00
|
|
|
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
|
2026-07-29 22:18:14 -04:00
|
|
|
var sender_public := NetworkIdentityCrypto.normalize_public_pem(
|
|
|
|
|
str(letter.get("sender_public_key", ""))
|
|
|
|
|
)
|
|
|
|
|
if (
|
|
|
|
|
not _session.matches_authenticated_session_identity(
|
|
|
|
|
str(letter["sender_fingerprint"]), sender_public
|
|
|
|
|
)
|
|
|
|
|
or not NetworkIdentityCrypto.verify_fields(
|
|
|
|
|
NetworkIdentityCrypto.load_public_key(sender_public),
|
|
|
|
|
"mail_send",
|
|
|
|
|
NetworkMailProtocol.mail_signature_fields(letter),
|
|
|
|
|
letter["sender_signature"],
|
|
|
|
|
)
|
|
|
|
|
):
|
|
|
|
|
return
|
2026-07-29 20:10:44 -04:00
|
|
|
_local_letters[letter["mail_id"]] = letter.duplicate(true)
|
|
|
|
|
_emit_mailbox()
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 23:10:51 -04:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 20:10:44 -04:00
|
|
|
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
|
2026-07-30 14:36:48 -04:00
|
|
|
if (
|
|
|
|
|
not bool(result.get("accepted", false))
|
|
|
|
|
or bool(result.get("silent_drop", false))
|
|
|
|
|
):
|
2026-07-29 20:10:44 -04:00
|
|
|
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
|
2026-07-29 22:18:14 -04:00
|
|
|
var letter: Dictionary = _local_letters.get(mail_id, {})
|
|
|
|
|
request["recipient_signature"] = _session.sign_local_action(
|
|
|
|
|
"mail_accept",
|
|
|
|
|
NetworkMailProtocol.acceptance_signature_fields(
|
|
|
|
|
request,
|
|
|
|
|
str(letter.get("sender_fingerprint", "")),
|
|
|
|
|
str(letter.get("recipient_fingerprint", "")),
|
|
|
|
|
),
|
|
|
|
|
)
|
2026-07-29 20:10:44 -04:00
|
|
|
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
|
2026-07-29 22:18:14 -04:00
|
|
|
var recipient_record := _session.get_peer_record(peer_id)
|
|
|
|
|
if (
|
|
|
|
|
recipient_record == null
|
|
|
|
|
or recipient_record.identity_fingerprint
|
|
|
|
|
!= str(letter.get("recipient_fingerprint", ""))
|
|
|
|
|
or not _session.verify_peer_action(
|
|
|
|
|
peer_id,
|
|
|
|
|
"mail_accept",
|
|
|
|
|
NetworkMailProtocol.acceptance_signature_fields(
|
|
|
|
|
data,
|
|
|
|
|
str(letter.get("sender_fingerprint", "")),
|
|
|
|
|
str(letter.get("recipient_fingerprint", "")),
|
|
|
|
|
),
|
|
|
|
|
data.get("recipient_signature", PackedByteArray()),
|
|
|
|
|
)
|
|
|
|
|
):
|
|
|
|
|
return
|
2026-07-29 20:10:44 -04:00
|
|
|
_action_ledger[action_id] = true
|
|
|
|
|
var transfer_id := _new_id("mail_transfer")
|
|
|
|
|
letter["state"] = NetworkMailProtocol.State.ACCEPTANCE_PENDING
|
2026-07-29 22:18:14 -04:00
|
|
|
letter["transfer_id"] = transfer_id
|
2026-07-29 20:10:44 -04:00
|
|
|
_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:
|
2026-07-30 00:54:53 -04:00
|
|
|
var recipient_ready: bool = _can_receive(letter["attachment"])
|
|
|
|
|
_send_phase_ack("recipient_prepared", transfer_id, recipient_ready)
|
2026-07-29 20:10:44 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
2026-07-30 14:01:07 -04:00
|
|
|
and _reservations.commit_removal(reservation_id, transfer_id)
|
2026-07-29 20:10:44 -04:00
|
|
|
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:
|
2026-07-29 22:18:14 -04:00
|
|
|
var letter := _find_local_transfer_letter(transfer_id)
|
|
|
|
|
var fields := _gift_phase_fields(phase, transfer_id, applied, letter)
|
|
|
|
|
var fingerprint := _session.get_local_identity_fingerprint()
|
|
|
|
|
var signature := _session.sign_local_action(
|
|
|
|
|
"gift_%s" % phase, fields
|
|
|
|
|
)
|
2026-07-29 20:10:44 -04:00
|
|
|
if _session.is_host():
|
2026-07-29 22:18:14 -04:00
|
|
|
_handle_phase_ack(
|
|
|
|
|
_session.get_local_peer_id(),
|
|
|
|
|
phase,
|
|
|
|
|
transfer_id,
|
|
|
|
|
applied,
|
|
|
|
|
fingerprint,
|
|
|
|
|
signature,
|
|
|
|
|
)
|
2026-07-29 20:10:44 -04:00
|
|
|
else:
|
2026-07-29 22:18:14 -04:00
|
|
|
acknowledge_transfer_phase.rpc_id(
|
|
|
|
|
1, phase, transfer_id, applied, fingerprint, signature
|
|
|
|
|
)
|
2026-07-29 20:10:44 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@rpc("any_peer", "call_remote", "reliable", NetworkMailProtocol.RELIABLE_CHANNEL)
|
|
|
|
|
func acknowledge_transfer_phase(
|
2026-07-29 22:18:14 -04:00
|
|
|
phase: String,
|
|
|
|
|
transfer_id: String,
|
|
|
|
|
applied: bool,
|
|
|
|
|
fingerprint: String,
|
|
|
|
|
signature: PackedByteArray,
|
2026-07-29 20:10:44 -04:00
|
|
|
) -> void:
|
|
|
|
|
var sender := multiplayer.get_remote_sender_id()
|
|
|
|
|
if _session.is_host() and _session.is_authenticated_peer(sender):
|
2026-07-29 22:18:14 -04:00
|
|
|
_handle_phase_ack(
|
|
|
|
|
sender, phase, transfer_id, applied, fingerprint, signature
|
|
|
|
|
)
|
2026-07-29 20:10:44 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
func _handle_phase_ack(
|
2026-07-29 22:18:14 -04:00
|
|
|
peer_id: int,
|
|
|
|
|
phase: String,
|
|
|
|
|
transfer_id: String,
|
|
|
|
|
applied: bool,
|
|
|
|
|
fingerprint: String,
|
|
|
|
|
signature: PackedByteArray,
|
2026-07-29 20:10:44 -04:00
|
|
|
) -> void:
|
|
|
|
|
var transfer: Dictionary = _pending_transfers.get(transfer_id, {})
|
|
|
|
|
if transfer.is_empty():
|
|
|
|
|
return
|
|
|
|
|
var letter: Dictionary = transfer["letter"]
|
2026-07-29 22:18:14 -04:00
|
|
|
var record := _session.get_peer_record(peer_id)
|
|
|
|
|
if (
|
|
|
|
|
record == null
|
|
|
|
|
or record.identity_fingerprint != fingerprint
|
|
|
|
|
or not _session.verify_peer_action(
|
|
|
|
|
peer_id,
|
|
|
|
|
"gift_%s" % phase,
|
|
|
|
|
_gift_phase_fields(phase, transfer_id, applied, letter),
|
|
|
|
|
signature,
|
|
|
|
|
)
|
|
|
|
|
):
|
|
|
|
|
return
|
2026-07-29 20:10:44 -04:00
|
|
|
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.")
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 22:18:14 -04:00
|
|
|
func _find_local_transfer_letter(transfer_id: String) -> Dictionary:
|
|
|
|
|
for value: Dictionary in _local_letters.values():
|
|
|
|
|
if str(value.get("transfer_id", "")) == transfer_id:
|
|
|
|
|
return value
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _gift_phase_fields(
|
|
|
|
|
phase: String,
|
|
|
|
|
transfer_id: String,
|
|
|
|
|
applied: bool,
|
|
|
|
|
letter: Dictionary,
|
|
|
|
|
) -> Array:
|
|
|
|
|
return [
|
|
|
|
|
_session.get_session_id(),
|
|
|
|
|
transfer_id,
|
|
|
|
|
str(letter.get("mail_id", "")),
|
|
|
|
|
str(letter.get("sender_fingerprint", "")),
|
|
|
|
|
str(letter.get("recipient_fingerprint", "")),
|
|
|
|
|
phase,
|
|
|
|
|
applied,
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 20:10:44 -04:00
|
|
|
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())
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 23:10:51 -04:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-07-30 14:36:48 -04:00
|
|
|
func _incoming_gift_is_unresolved(letter: Dictionary) -> bool:
|
|
|
|
|
return (
|
|
|
|
|
is_local_recipient(letter)
|
|
|
|
|
and int(letter.get("attachment", {}).get("type", 0))
|
|
|
|
|
!= PlayerAssetReservationService.AttachmentType.NONE
|
|
|
|
|
and int(letter.get("state", -1)) in [
|
|
|
|
|
NetworkMailProtocol.State.SENT_UNREAD,
|
|
|
|
|
NetworkMailProtocol.State.READ,
|
|
|
|
|
NetworkMailProtocol.State.ACCEPTANCE_PENDING,
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 20:10:44 -04:00
|
|
|
func _on_peer_removed(peer_id: int) -> void:
|
|
|
|
|
if not _session.is_host():
|
2026-07-30 14:36:48 -04:00
|
|
|
# The host delivers any unresolved final state. Keep local copies so
|
|
|
|
|
# disconnect feedback cannot disappear or overwrite settled mail.
|
|
|
|
|
peers_changed.emit()
|
2026-07-29 20:10:44 -04:00
|
|
|
return
|
2026-07-30 14:36:48 -04:00
|
|
|
for transfer_id: String in _pending_transfers.keys().duplicate():
|
|
|
|
|
var transfer: Dictionary = _pending_transfers.get(transfer_id, {})
|
|
|
|
|
var transfer_letter: Dictionary = transfer.get("letter", {})
|
|
|
|
|
if peer_id not in [
|
|
|
|
|
int(transfer_letter.get("sender_peer_id", 0)),
|
|
|
|
|
int(transfer_letter.get("recipient_peer_id", 0)),
|
|
|
|
|
]:
|
|
|
|
|
continue
|
|
|
|
|
if bool(transfer.get("sender_removed", false)):
|
|
|
|
|
var sender_id := int(transfer_letter.get("sender_peer_id", 0))
|
|
|
|
|
if _session.is_authenticated_peer(sender_id):
|
|
|
|
|
_request_sender_rollback(sender_id, transfer_id)
|
|
|
|
|
_finish_transfer(transfer_id, false, "Gift returned to sender.")
|
2026-07-29 20:10:44 -04:00
|
|
|
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)
|
|
|
|
|
peers_changed.emit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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()
|
2026-07-30 14:36:48 -04:00
|
|
|
_archived_local_letters.clear()
|
|
|
|
|
_deleted_local_letters.clear()
|
2026-07-29 20:10:44 -04:00
|
|
|
_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(),
|
|
|
|
|
]
|