feat: expand progression and multiplayer systems
Add named save slots, progression import/export, and a unified play flow. Add live friend requests, presence, invitations, and relationship controls without durable discovery-server social storage. Advance the network protocol with isolated channels, movement reconciliation, late-join recovery, fishing replication, and animation synchronization. Preserve per-species catch totals, refine generated-world startup and water recovery, and complete the related input and interface improvements.
This commit is contained in:
parent
1db1a5b754
commit
3b84bfe3a0
97 changed files with 7869 additions and 982 deletions
|
|
@ -3,8 +3,12 @@ extends Node
|
|||
|
||||
signal relationship_changed(fingerprint: String)
|
||||
|
||||
const FORMAT_VERSION := 1
|
||||
const FORMAT_VERSION := 2
|
||||
const MAX_RECORDS := 500
|
||||
const MAX_FRIENDS := 200
|
||||
const SOCIAL_TOKEN_LENGTH := 64
|
||||
const PRESENCE_CHANNEL_DOMAIN := "NETFISHING_PRESENCE_V1:"
|
||||
const INVITE_INBOX_DOMAIN := "NETFISHING_INVITE_V1:"
|
||||
|
||||
var _records: Dictionary = {}
|
||||
var _loaded := false
|
||||
|
|
@ -29,6 +33,11 @@ func is_blocked(fingerprint: String) -> bool:
|
|||
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
|
||||
|
|
@ -48,20 +57,101 @@ func set_blocked(fingerprint: String, display_name: String, value: bool) -> bool
|
|||
# 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))
|
||||
result.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
||||
return str(a.get("last_known_display_name", "")).naturalnocasecmp_to(
|
||||
str(b.get("last_known_display_name", ""))
|
||||
) < 0
|
||||
)
|
||||
_sort_records(result)
|
||||
return result
|
||||
|
||||
|
||||
|
|
@ -72,6 +162,7 @@ func _record(fingerprint: String, display_name: String) -> Dictionary:
|
|||
"created_unix": now,
|
||||
"muted": false,
|
||||
"blocked": false,
|
||||
"friend": false,
|
||||
})
|
||||
record["last_known_display_name"] = display_name
|
||||
record["updated_unix"] = now
|
||||
|
|
@ -82,7 +173,11 @@ func _commit(fingerprint: String, record: Dictionary) -> bool:
|
|||
if _write_blocked:
|
||||
return false
|
||||
var previous: Dictionary = _records.duplicate(true)
|
||||
if not bool(record["muted"]) and not bool(record["blocked"]):
|
||||
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
|
||||
|
|
@ -115,7 +210,8 @@ func _ensure_loaded() -> void:
|
|||
if json.parse(file.get_as_text()) != OK or typeof(json.data) != TYPE_DICTIONARY:
|
||||
return
|
||||
var data: Dictionary = json.data
|
||||
if data.get("format_version") != FORMAT_VERSION:
|
||||
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", []):
|
||||
|
|
@ -127,6 +223,13 @@ func _ensure_loaded() -> void:
|
|||
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)
|
||||
|
||||
|
|
@ -145,3 +248,69 @@ func _save() -> bool:
|
|||
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
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue