straywild/network/player_relationship_store.gd

316 lines
9.4 KiB
GDScript

class_name PlayerRelationshipStore
extends Node
signal relationship_changed(fingerprint: String)
const FORMAT_VERSION := 2
const MAX_RECORDS := 500
const MAX_FRIENDS := 200
const SOCIAL_TOKEN_LENGTH := 64
const PRESENCE_CHANNEL_DOMAIN := "straywild_PRESENCE_V1:"
const INVITE_INBOX_DOMAIN := "straywild_INVITE_V1:"
var _records: Dictionary = {}
var _loaded := false
var _write_blocked := false
var _store_path := ""
var _expected_hash := ""
var _data_root: PlayerDataRoot
func configure_storage(path: String, data_root: PlayerDataRoot) -> void:
_store_path = path
_data_root = data_root
func is_muted(fingerprint: String) -> bool:
_ensure_loaded()
return bool(_records.get(fingerprint, {}).get("muted", false))
func is_blocked(fingerprint: String) -> bool:
_ensure_loaded()
return bool(_records.get(fingerprint, {}).get("blocked", false))
func is_friend(fingerprint: String) -> bool:
_ensure_loaded()
return bool(_records.get(fingerprint, {}).get("friend", false))
func set_muted(fingerprint: String, display_name: String, value: bool) -> bool:
if not _valid_target(fingerprint, display_name):
return false
_ensure_loaded()
var record := _record(fingerprint, display_name)
record["muted"] = value or bool(record.get("blocked", false))
return _commit(fingerprint, record)
func set_blocked(fingerprint: String, display_name: String, value: bool) -> bool:
if not _valid_target(fingerprint, display_name):
return false
_ensure_loaded()
var record := _record(fingerprint, display_name)
record["blocked"] = value
# Blocking owns the accompanying mute. Removing the block establishes a
# fresh visibility boundary for future messages; already suppressed
# messages retain their immutable local suppression flag.
record["muted"] = value
if value:
_clear_friend_fields(record)
return _commit(fingerprint, record)
func create_friend_capabilities() -> Dictionary:
var presence_write_token := NetworkIdentityCrypto.secure_id(32)
var invite_token := NetworkIdentityCrypto.secure_id(32)
if not _valid_social_token(presence_write_token) or not _valid_social_token(invite_token):
return {}
return {
"local_presence_write_token": presence_write_token,
"local_invite_token": invite_token,
"presence_channel": presence_channel_for_write_token(
presence_write_token
),
"invite_token": invite_token,
}
func add_friend(
fingerprint: String,
display_name: String,
local_capabilities: Dictionary,
remote_capabilities: Dictionary,
) -> bool:
if (
not _valid_target(fingerprint, display_name)
or is_blocked(fingerprint)
or not _valid_local_capabilities(local_capabilities)
or not _valid_public_capabilities(remote_capabilities)
):
return false
_ensure_loaded()
if not is_friend(fingerprint) and get_friends().size() >= MAX_FRIENDS:
return false
var record := _record(fingerprint, display_name)
var now := int(Time.get_unix_time_from_system())
record["friend"] = true
record["friend_since_unix"] = int(record.get("friend_since_unix", now))
record["local_presence_write_token"] = str(
local_capabilities["local_presence_write_token"]
)
record["local_invite_token"] = str(
local_capabilities["local_invite_token"]
)
record["remote_presence_channel"] = str(
remote_capabilities["presence_channel"]
)
record["remote_invite_token"] = str(
remote_capabilities["invite_token"]
)
return _commit(fingerprint, record)
func remove_friend(fingerprint: String, display_name: String) -> bool:
if not _valid_target(fingerprint, display_name):
return false
_ensure_loaded()
var record := _record(fingerprint, display_name)
_clear_friend_fields(record)
return _commit(fingerprint, record)
func get_friend_record(fingerprint: String) -> Dictionary:
_ensure_loaded()
var record: Dictionary = _records.get(fingerprint, {})
return record.duplicate(true) if bool(record.get("friend", false)) else {}
func get_friends() -> Array[Dictionary]:
_ensure_loaded()
var result: Array[Dictionary] = []
for value: Dictionary in _records.values():
if bool(value.get("friend", false)):
result.append(value.duplicate(true))
_sort_records(result)
return result
static func presence_channel_for_write_token(write_token: String) -> String:
return (PRESENCE_CHANNEL_DOMAIN + write_token).sha256_text()
static func invite_inbox_id(invite_token: String) -> String:
return (INVITE_INBOX_DOMAIN + invite_token).sha256_text()
func get_records() -> Array[Dictionary]:
_ensure_loaded()
var result: Array[Dictionary] = []
for value: Dictionary in _records.values():
if bool(value.get("muted", false)) or bool(value.get("blocked", false)):
result.append(value.duplicate(true))
_sort_records(result)
return result
func _record(fingerprint: String, display_name: String) -> Dictionary:
var now := int(Time.get_unix_time_from_system())
var record: Dictionary = _records.get(fingerprint, {
"fingerprint": fingerprint,
"created_unix": now,
"muted": false,
"blocked": false,
"friend": false,
})
record["last_known_display_name"] = display_name
record["updated_unix"] = now
return record
func _commit(fingerprint: String, record: Dictionary) -> bool:
if _write_blocked:
return false
var previous: Dictionary = _records.duplicate(true)
if (
not bool(record.get("muted", false))
and not bool(record.get("blocked", false))
and not bool(record.get("friend", false))
):
_records.erase(fingerprint)
else:
_records[fingerprint] = record
while _records.size() > MAX_RECORDS:
_records.erase(_records.keys().front())
if not _save():
_records = previous
return false
relationship_changed.emit(fingerprint)
return true
func _valid_target(fingerprint: String, display_name: String) -> bool:
return (
NetworkIdentityCrypto.valid_fingerprint(fingerprint)
and NetworkProfilePreferences.is_valid_display_name(display_name)
)
func _ensure_loaded() -> void:
if _loaded:
return
_loaded = true
if _store_path.is_empty() or not FileAccess.file_exists(_store_path):
return
var file := FileAccess.open(_store_path, FileAccess.READ)
if file == null:
return
var json := JSON.new()
if json.parse(file.get_as_text()) != OK or typeof(json.data) != TYPE_DICTIONARY:
return
var data: Dictionary = json.data
var format_version: int = int(data.get("format_version", 0))
if format_version not in [1, FORMAT_VERSION]:
_write_blocked = true
return
for value: Variant in data.get("records", []):
if typeof(value) != TYPE_DICTIONARY:
continue
var record: Dictionary = value
var fingerprint := str(record.get("fingerprint", ""))
if not NetworkIdentityCrypto.valid_fingerprint(fingerprint):
continue
record["blocked"] = bool(record.get("blocked", false))
record["muted"] = bool(record.get("muted", false)) or record["blocked"]
record["friend"] = (
bool(record.get("friend", false))
and not record["blocked"]
and _valid_stored_friend_capabilities(record)
)
if not record["friend"]:
_clear_friend_fields(record)
_records[fingerprint] = record.duplicate(true)
_expected_hash = PortableFileGuard.hash_file(_store_path)
func _save() -> bool:
var bytes := JSON.stringify({
"format_version": FORMAT_VERSION,
"records": _records.values(),
}, "\t").to_utf8_buffer()
var result := PortableFileGuard.write_guarded(
_store_path, bytes, _expected_hash, _data_root.conflict_directory(),
_data_root.device_id,
)
if bool(result.get("conflict", false)):
_data_root.report_conflict(str(result.get("message", "")), str(result.get("conflict_path", "")))
if bool(result.get("ok", false)):
_expected_hash = str(result["hash"])
return bool(result.get("ok", false))
func _clear_friend_fields(record: Dictionary) -> void:
record["friend"] = false
for key: String in [
"friend_since_unix",
"local_presence_write_token",
"local_invite_token",
"remote_presence_channel",
"remote_invite_token",
]:
record.erase(key)
func _valid_local_capabilities(value: Dictionary) -> bool:
var write_token := str(value.get("local_presence_write_token", ""))
var invite_token := str(value.get("local_invite_token", ""))
return (
_valid_social_token(write_token)
and _valid_social_token(invite_token)
and str(value.get("presence_channel", ""))
== presence_channel_for_write_token(write_token)
and str(value.get("invite_token", "")) == invite_token
)
func _valid_public_capabilities(value: Dictionary) -> bool:
return (
_valid_social_token(str(value.get("presence_channel", "")))
and _valid_social_token(str(value.get("invite_token", "")))
)
func _valid_stored_friend_capabilities(record: Dictionary) -> bool:
var local: Dictionary = {
"local_presence_write_token": str(
record.get("local_presence_write_token", "")
),
"local_invite_token": str(record.get("local_invite_token", "")),
"presence_channel": presence_channel_for_write_token(str(
record.get("local_presence_write_token", "")
)),
"invite_token": str(record.get("local_invite_token", "")),
}
var remote: Dictionary = {
"presence_channel": str(record.get("remote_presence_channel", "")),
"invite_token": str(record.get("remote_invite_token", "")),
}
return _valid_local_capabilities(local) and _valid_public_capabilities(remote)
func _valid_social_token(value: String) -> bool:
if value.length() != SOCIAL_TOKEN_LENGTH:
return false
for character: String in value:
if character not in "0123456789abcdef":
return false
return true
func _sort_records(records: Array[Dictionary]) -> void:
records.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return str(a.get("last_known_display_name", "")).naturalnocasecmp_to(
str(b.get("last_known_display_name", ""))
) < 0
)