Integrate per-save identity and interaction UI

This commit is contained in:
Alexander Sellite 2026-09-01 17:30:50 -04:00
parent ac82a28f3b
commit 4fda6e97f6
58 changed files with 3471 additions and 461 deletions

View file

@ -278,7 +278,10 @@ func get_friend_presence() -> Array[Dictionary]:
result.append({
"fingerprint": fingerprint,
"display_name": str(
friend.get("last_known_display_name", "Player")
friend.get(
"last_known_display_name",
NetworkProfilePreferences.DEFAULT_DISPLAY_NAME,
)
),
"online": bool(live.get("online", false)),
"room": (
@ -1444,7 +1447,10 @@ func _on_invitation_poll_completed(
continue
friend_invite_received.emit(
fingerprint,
str(friend.get("last_known_display_name", "Player")),
str(friend.get(
"last_known_display_name",
NetworkProfilePreferences.DEFAULT_DISPLAY_NAME,
)),
room.duplicate(true),
)

View file

@ -15,6 +15,9 @@ static var _invisible_edge_pattern: RegEx = RegEx.create_from_string(
static var _visible_content_pattern: RegEx = RegEx.create_from_string(
"[^\\s\\p{Z}\\p{Cf}\\x{2800}]"
)
static var _roleplay_separator_pattern: RegEx = RegEx.create_from_string(
"^[\\s\\p{Z}\\p{Cf}\\x{2800}]"
)
enum Kind { PLAYER, SYSTEM }
@ -40,12 +43,40 @@ static func sanitize_body(value: Variant) -> String:
return result
static func is_roleplay_body(value: Variant) -> bool:
var body: String = sanitize_body(value)
if body.length() < 3 or body.left(3).to_lower() != "/me":
return false
return (
body.length() == 3
or _roleplay_separator_pattern.search(body.substr(3, 1)) != null
)
static func roleplay_action(value: Variant) -> String:
var body: String = sanitize_body(value)
if not is_roleplay_body(body):
return ""
return sanitize_body(body.substr(3))
static func valid_user_body(value: Variant) -> bool:
var body: String = sanitize_body(value)
return (
not body.is_empty()
and (
not is_roleplay_body(body)
or not roleplay_action(body).is_empty()
)
)
static func validate_request(data: Variant) -> bool:
return (
typeof(data) == TYPE_DICTIONARY
and _valid_id(data.get("request_id"))
and _valid_id(data.get("session_id"))
and not sanitize_body(data.get("body")).is_empty()
and valid_user_body(data.get("body"))
and NetworkIdentityCrypto.valid_fingerprint(
data.get("sender_fingerprint")
)
@ -67,7 +98,16 @@ static func validate_message(data: Variant) -> bool:
and typeof(data.get("sender_peer_id")) == TYPE_INT
and typeof(data.get("sender_display_name")) == TYPE_STRING
and str(data["sender_display_name"]).length() <= 24
and not sanitize_body(data.get("body")).is_empty()
and (
(
int(data["kind"]) == Kind.SYSTEM
and not sanitize_body(data.get("body")).is_empty()
)
or (
int(data["kind"]) == Kind.PLAYER
and valid_user_body(data.get("body"))
)
)
and NetworkIdentityCrypto.valid_fingerprint(
data.get("sender_fingerprint")
)

View file

@ -115,7 +115,10 @@ func send_local_message(
send_rejected.emit("Chat is unavailable.")
return false
var clean := NetworkChatProtocol.sanitize_body(body)
if clean.is_empty():
if not NetworkChatProtocol.valid_user_body(clean):
if NetworkChatProtocol.is_roleplay_body(clean):
send_rejected.emit("Usage: /me [action].")
return false
send_rejected.emit("Message is empty or too long.")
return false
var request := {
@ -412,6 +415,7 @@ func _apply_message(
return
var kind := int(data["kind"])
var valid_signature := false
var canonical_sender_name: String = ""
if kind == NetworkChatProtocol.Kind.SYSTEM:
valid_signature = _session.verify_host_action(
"chat_system",
@ -424,6 +428,7 @@ func _apply_message(
if record == null:
_queue_pending_peer_message(sender_id, data, emit_live_signals)
return
canonical_sender_name = record.display_name.left(24)
valid_signature = (
record.identity_fingerprint == str(data["sender_fingerprint"])
and _session.verify_peer_action(
@ -437,6 +442,10 @@ func _apply_message(
return
_seen_messages[data["message_id"]] = true
var stored_message := data.duplicate(true)
if kind == NetworkChatProtocol.Kind.PLAYER:
# The authenticated peer registry, not sender-controlled message data,
# owns the name shown beside chat and roleplay actions.
stored_message["sender_display_name"] = canonical_sender_name
# Suppression is immutable for this received copy. Relationship changes
# later must never resurrect text that was hidden on arrival.
stored_message["locally_suppressed"] = (
@ -548,7 +557,9 @@ func _on_peer_removed(peer_id: int) -> void:
_pending_peer_messages.erase(peer_id)
if not _session.is_host():
return
var display_name: String = _peer_names.get(peer_id, "Player")
var display_name: String = _peer_names.get(
peer_id, NetworkProfilePreferences.DEFAULT_DISPLAY_NAME
)
_peer_names.erase(peer_id)
_broadcast(_make_message(
NetworkChatProtocol.Kind.SYSTEM, 0, "", "%s left." % display_name

View file

@ -152,7 +152,10 @@ func is_local_recipient(letter: Dictionary) -> bool:
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"
return (
record.display_name
if record != null else NetworkProfilePreferences.DEFAULT_DISPLAY_NAME
)
func get_recipient_choices() -> Array[Dictionary]:

View file

@ -477,7 +477,10 @@ func request_ban(peer_id: int, fingerprint: String) -> void:
if not _valid_operator_sender(sender_id):
return
var record: PeerRegistry.PeerRecord = _session.get_peer_record(peer_id)
var display_name: String = record.display_name if record != null else "Player"
var display_name: String = (
record.display_name
if record != null else NetworkProfilePreferences.DEFAULT_DISPLAY_NAME
)
var ok: bool = _ban_on_host(
peer_id, fingerprint, display_name, -1, false
)
@ -544,7 +547,10 @@ func receive_ban_snapshot(records: Array) -> void:
var record: Dictionary = value
var fingerprint: String = str(record.get("target_fingerprint", ""))
var display_name: String = str(
record.get("last_known_display_name", "Player")
record.get(
"last_known_display_name",
NetworkProfilePreferences.DEFAULT_DISPLAY_NAME,
)
).strip_edges().left(NetworkProtocol.MAX_DISPLAY_NAME_LENGTH)
if (
not NetworkIdentityCrypto.valid_fingerprint(fingerprint)

View file

@ -1,12 +1,17 @@
class_name NetworkProfilePreferences
extends Node
signal profile_changed
const FORMAT_VERSION: int = 1
const DEFAULT_DISPLAY_NAME: String = "stray"
const DEFAULT_TITLE: String = "newbie"
const VoiceProfilesType = preload(
"res://player/animalese_voice_profiles.gd"
)
var profile_id: String = ""
var display_name: String = "Player"
var display_name: String = DEFAULT_DISPLAY_NAME
var title: String = DEFAULT_TITLE
var voice_id: String = VoiceProfilesType.DEFAULT_ID
var speech_speed_id: String = VoiceProfilesType.DEFAULT_SPEED_ID
var call_id: String = VoiceProfilesType.DEFAULT_CALL_ID
@ -56,7 +61,8 @@ func load_or_create() -> bool:
int(Time.get_unix_time_from_system()),
Time.get_ticks_usec(),
]
display_name = "Player"
display_name = DEFAULT_DISPLAY_NAME
title = DEFAULT_TITLE
voice_id = VoiceProfilesType.DEFAULT_ID
speech_speed_id = VoiceProfilesType.DEFAULT_SPEED_ID
call_id = VoiceProfilesType.DEFAULT_CALL_ID
@ -65,6 +71,113 @@ func load_or_create() -> bool:
return _save_atomic()
func to_save_data() -> Dictionary:
return {
"profile_id": profile_id,
"display_name": display_name,
"title": title,
"voice_id": voice_id,
"speech_speed_id": speech_speed_id,
"call_id": call_id,
"sample_set_id": sample_set_id,
"created_at_unix": created_at_unix,
}
static func validate_save_data(value: Variant) -> bool:
if typeof(value) != TYPE_DICTIONARY:
return false
var data: Dictionary = value
if (
typeof(data.get("profile_id")) != TYPE_STRING
or typeof(data.get("display_name")) != TYPE_STRING
or (
data.has("title")
and typeof(data.get("title")) != TYPE_STRING
)
or typeof(data.get("created_at_unix")) not in [TYPE_INT, TYPE_FLOAT]
):
return false
var saved_profile_id := str(data.get("profile_id", ""))
var saved_name := str(data.get("display_name", ""))
var saved_title := str(data.get("title", DEFAULT_TITLE))
return (
not saved_profile_id.is_empty()
and saved_profile_id.length() <= NetworkProtocol.MAX_PROFILE_ID_LENGTH
and is_valid_display_name(saved_name)
and is_valid_title(saved_title)
and VoiceProfilesType.is_valid(str(data.get(
"voice_id",
VoiceProfilesType.DEFAULT_ID,
)))
and VoiceProfilesType.is_valid_speed(str(data.get(
"speech_speed_id",
VoiceProfilesType.DEFAULT_SPEED_ID,
)))
and VoiceProfilesType.is_valid_call(str(data.get(
"call_id",
VoiceProfilesType.DEFAULT_CALL_ID,
)))
and VoiceProfilesType.is_valid_sample_set(str(data.get(
"sample_set_id",
VoiceProfilesType.DEFAULT_SAMPLE_SET_ID,
)))
and int(data.get("created_at_unix", -1)) >= 0
)
func restore_from_save_data(
value: Variant,
persist_to_profile_file: bool = true,
) -> bool:
if not validate_save_data(value):
return false
var data: Dictionary = value
var previous: Dictionary = to_save_data()
profile_id = str(data["profile_id"])
display_name = str(data["display_name"]).strip_edges()
title = str(data.get("title", DEFAULT_TITLE)).strip_edges()
voice_id = str(data.get("voice_id", VoiceProfilesType.DEFAULT_ID))
speech_speed_id = str(data.get(
"speech_speed_id",
VoiceProfilesType.DEFAULT_SPEED_ID,
))
call_id = str(data.get("call_id", VoiceProfilesType.DEFAULT_CALL_ID))
sample_set_id = str(data.get(
"sample_set_id",
VoiceProfilesType.DEFAULT_SAMPLE_SET_ID,
))
created_at_unix = int(data["created_at_unix"])
if persist_to_profile_file and not _save_atomic():
_assign_save_data(previous)
return false
if to_save_data() != previous:
profile_changed.emit()
return true
func reset_to_defaults(persist_to_profile_file: bool = true) -> bool:
var previous: Dictionary = to_save_data()
profile_id = Crypto.new().generate_random_bytes(16).hex_encode()
if profile_id.is_empty():
profile_id = "%d-%d" % [
int(Time.get_unix_time_from_system()),
Time.get_ticks_usec(),
]
display_name = DEFAULT_DISPLAY_NAME
title = DEFAULT_TITLE
voice_id = VoiceProfilesType.DEFAULT_ID
speech_speed_id = VoiceProfilesType.DEFAULT_SPEED_ID
call_id = VoiceProfilesType.DEFAULT_CALL_ID
sample_set_id = VoiceProfilesType.DEFAULT_SAMPLE_SET_ID
created_at_unix = int(Time.get_unix_time_from_system())
if persist_to_profile_file and not _save_atomic():
_assign_save_data(previous)
return false
profile_changed.emit()
return true
func set_display_name(value: String) -> bool:
return set_profile_identity(
value,
@ -72,6 +185,7 @@ func set_display_name(value: String) -> bool:
speech_speed_id,
call_id,
sample_set_id,
title,
)
@ -81,10 +195,15 @@ func set_profile_identity(
selected_speech_speed_id: String,
selected_call_id: String,
selected_sample_set_id: String = VoiceProfilesType.DEFAULT_SAMPLE_SET_ID,
selected_title: String = "",
) -> bool:
var clean_name: String = value.strip_edges()
var clean_title: String = (
title if selected_title.is_empty() else selected_title.strip_edges()
)
if (
not is_valid_display_name(clean_name)
or not is_valid_title(clean_title)
or not VoiceProfilesType.is_valid(selected_voice_id)
or not VoiceProfilesType.is_valid_speed(selected_speech_speed_id)
or not VoiceProfilesType.is_valid_call(selected_call_id)
@ -92,18 +211,30 @@ func set_profile_identity(
):
return false
var previous_name: String = display_name
var previous_title: String = title
var previous_voice_id: String = voice_id
var previous_speech_speed_id: String = speech_speed_id
var previous_call_id: String = call_id
var previous_sample_set_id: String = sample_set_id
display_name = clean_name
title = clean_title
voice_id = selected_voice_id
speech_speed_id = selected_speech_speed_id
call_id = selected_call_id
sample_set_id = selected_sample_set_id
if _save_atomic():
if (
display_name != previous_name
or voice_id != previous_voice_id
or title != previous_title
or speech_speed_id != previous_speech_speed_id
or call_id != previous_call_id
or sample_set_id != previous_sample_set_id
):
profile_changed.emit()
return true
display_name = previous_name
title = previous_title
voice_id = previous_voice_id
speech_speed_id = previous_speech_speed_id
call_id = previous_call_id
@ -111,6 +242,23 @@ func set_profile_identity(
return false
func _assign_save_data(data: Dictionary) -> void:
profile_id = str(data.get("profile_id", ""))
display_name = str(data.get("display_name", DEFAULT_DISPLAY_NAME))
title = str(data.get("title", DEFAULT_TITLE))
voice_id = str(data.get("voice_id", VoiceProfilesType.DEFAULT_ID))
speech_speed_id = str(data.get(
"speech_speed_id",
VoiceProfilesType.DEFAULT_SPEED_ID,
))
call_id = str(data.get("call_id", VoiceProfilesType.DEFAULT_CALL_ID))
sample_set_id = str(data.get(
"sample_set_id",
VoiceProfilesType.DEFAULT_SAMPLE_SET_ID,
))
created_at_unix = int(data.get("created_at_unix", 0))
static func is_valid_display_name(value: String) -> bool:
var clean_name := value.strip_edges()
if clean_name.is_empty() or clean_name.length() > 24:
@ -122,6 +270,17 @@ static func is_valid_display_name(value: String) -> bool:
return true
static func is_valid_title(value: String) -> bool:
var clean_title := value.strip_edges()
if clean_title.is_empty() or clean_title.length() > NetworkProtocol.MAX_PLAYER_TITLE_LENGTH:
return false
for index: int in clean_title.length():
var codepoint: int = clean_title.unicode_at(index)
if codepoint < 32 or codepoint == 127:
return false
return true
func _load_existing() -> bool:
var file := FileAccess.open(_profile_path, FileAccess.READ)
if file == null:
@ -142,20 +301,27 @@ func _load_existing() -> bool:
data.get("format_version") != FORMAT_VERSION
or typeof(data.get("profile_id")) != TYPE_STRING
or typeof(data.get("display_name")) != TYPE_STRING
or (
data.has("title")
and typeof(data.get("title")) != TYPE_STRING
)
or typeof(data.get("created_at_unix")) not in [TYPE_INT, TYPE_FLOAT]
):
return false
var loaded_id: String = data["profile_id"]
var loaded_name: String = data["display_name"]
var loaded_title := str(data.get("title", DEFAULT_TITLE))
if (
loaded_id.is_empty()
or loaded_id.length() > NetworkProtocol.MAX_PROFILE_ID_LENGTH
or loaded_name.is_empty()
or loaded_name.length() > NetworkProtocol.MAX_DISPLAY_NAME_LENGTH
or not is_valid_title(loaded_title)
):
return false
profile_id = loaded_id
display_name = loaded_name
title = loaded_title.strip_edges()
voice_id = VoiceProfilesType.sanitized_id(
str(data.get("voice_id", VoiceProfilesType.DEFAULT_ID))
)
@ -180,6 +346,7 @@ func _save_atomic() -> bool:
"format_version": FORMAT_VERSION,
"profile_id": profile_id,
"display_name": display_name,
"title": title,
"voice_id": voice_id,
"speech_speed_id": speech_speed_id,
"call_id": call_id,

View file

@ -31,6 +31,8 @@ static func valid_check_request(data: Variant) -> bool:
static func valid_apply_request(data: Variant) -> bool:
return (
valid_check_request(data)
and typeof(data.get("player_title")) == TYPE_STRING
and NetworkProfilePreferences.is_valid_title(data["player_title"])
and typeof(data.get("appearance")) == TYPE_DICTIONARY
and valid_snapshot(data["appearance"])
and typeof(data.get("use_anyway")) == TYPE_BOOL
@ -63,6 +65,7 @@ static func signature_fields(data: Dictionary) -> Array:
str(data.get("request_id", "")),
str(data.get("sender_fingerprint", "")),
str(data.get("display_name", "")),
str(data.get("player_title", "")),
]
result.append_array(
CharacterCustomizationCatalog.appearance_signature_values(appearance)

View file

@ -16,6 +16,7 @@ signal profile_snapshot_changed(
display_name: String,
appearance: Dictionary,
)
signal profile_title_changed(peer_id: int, title: String)
var _session: NetworkSession
var _preferences: NetworkProfilePreferences
@ -26,6 +27,7 @@ var _pending_voice_ids: Dictionary[String, String] = {}
var _pending_speech_speed_ids: Dictionary[String, String] = {}
var _pending_call_ids: Dictionary[String, String] = {}
var _pending_sample_set_ids: Dictionary[String, String] = {}
var _pending_titles: Dictionary[String, String] = {}
var _host_pending_apply: Dictionary[String, Dictionary] = {}
var _latest_check_id: String = ""
var _latest_check_name: String = ""
@ -42,6 +44,16 @@ func setup(
_appearance_store = appearance_store
_spawn_service = spawn_service
_appearance_store.load_preferences()
if not _preferences.profile_changed.is_connected(
_on_local_profile_store_changed
):
_preferences.profile_changed.connect(_on_local_profile_store_changed)
if not _appearance_store.appearance_changed.is_connected(
_on_local_appearance_store_changed
):
_appearance_store.appearance_changed.connect(
_on_local_appearance_store_changed
)
_session.peer_authenticated.connect(_on_peer_authenticated)
_session.peer_removed.connect(_on_peer_removed)
_session.peer_display_name_changed.connect(_on_peer_display_name_changed)
@ -51,6 +63,20 @@ func setup(
_apply_local_voice_to_avatar()
func _on_local_profile_store_changed() -> void:
_apply_local_voice_to_avatar()
func _on_local_appearance_store_changed() -> void:
var snapshot: Dictionary = _appearance_store.get_snapshot()
var local_peer_id: int = (
_session.get_local_peer_id() if _session != null else 1
)
_apply_to_avatar(local_peer_id, snapshot)
if _session != null:
_session.set_local_appearance_snapshot(snapshot)
func reload_persisted_profile() -> bool:
if (
not _preferences.load_or_create()
@ -66,6 +92,10 @@ func get_persisted_name() -> String:
return _preferences.display_name
func get_persisted_title() -> String:
return _preferences.title
func get_persisted_appearance() -> Dictionary:
return _appearance_store.get_snapshot()
@ -107,6 +137,7 @@ func preview_appearance(appearance: Dictionary) -> bool:
local_peer_id,
_preferences.display_name,
snapshot,
_preferences.title,
)
var authorization := _make_appearance_preview_authorization(snapshot)
var local_record := _session.get_peer_record(local_peer_id)
@ -179,10 +210,16 @@ func apply_profile(
speech_speed_id: String = VoiceProfilesType.DEFAULT_SPEED_ID,
call_id: String = VoiceProfilesType.DEFAULT_CALL_ID,
sample_set_id: String = VoiceProfilesType.DEFAULT_SAMPLE_SET_ID,
player_title: String = "",
) -> bool:
var clean_name := display_name.strip_edges()
var clean_title := (
_preferences.title
if player_title.is_empty() else player_title.strip_edges()
)
if (
not NetworkProfilePreferences.is_valid_display_name(clean_name)
or not NetworkProfilePreferences.is_valid_title(clean_title)
or not CharacterCustomizationCatalog.validate_snapshot(appearance)
or not VoiceProfilesType.is_valid(voice_id)
or not VoiceProfilesType.is_valid_speed(speech_speed_id)
@ -196,6 +233,7 @@ func apply_profile(
"request_id": request_id,
"session_id": _session.get_session_id() if _session != null else "",
"display_name": clean_name,
"player_title": clean_title,
"appearance": appearance.duplicate(true),
"use_anyway": use_anyway,
"sender_fingerprint": _session.get_local_identity_fingerprint(),
@ -208,6 +246,7 @@ func apply_profile(
_pending_speech_speed_ids[request_id] = speech_speed_id
_pending_call_ids[request_id] = call_id
_pending_sample_set_ids[request_id] = sample_set_id
_pending_titles[request_id] = clean_title
if _session == null or not _session.is_gameplay_session_active():
_apply_local_result(request_id, true, "", false, PackedStringArray())
elif _session.is_host():
@ -303,6 +342,7 @@ func submit_appearance_preview(data: Dictionary) -> void:
sender_id,
record.display_name,
appearance,
record.title,
)
_apply_to_avatar(sender_id, appearance)
_broadcast_appearance_preview_to_supported_peers(sender_id, appearance)
@ -310,6 +350,7 @@ func submit_appearance_preview(data: Dictionary) -> void:
func _process_apply_request(peer_id: int, data: Dictionary) -> void:
var display_name: String = str(data["display_name"]).strip_edges()
var player_title: String = str(data["player_title"]).strip_edges()
var conflict: bool = _name_conflicts(peer_id, display_name)
var accepted: bool = not conflict or bool(data["use_anyway"])
var suggestions: PackedStringArray = (
@ -325,7 +366,8 @@ func _process_apply_request(peer_id: int, data: Dictionary) -> void:
if accepted:
_host_pending_apply[str(data["request_id"])] = {
"peer_id": peer_id,
"display_name": display_name,
"display_name": display_name,
"player_title": player_title,
"appearance": Dictionary(data["appearance"]).duplicate(true),
"authorization": {
"request_id": data["request_id"],
@ -356,8 +398,11 @@ func confirm_profile_saved(request_id: String) -> void:
return
_host_pending_apply.erase(request_id)
var display_name: String = str(pending["display_name"])
var player_title: String = str(pending["player_title"])
var appearance: Dictionary = Dictionary(pending["appearance"])
_session.apply_canonical_profile(sender_id, display_name, appearance)
_session.apply_canonical_profile(
sender_id, display_name, appearance, player_title
)
var record: PeerRegistry.PeerRecord = _session.get_peer_record(sender_id)
if record != null:
record.profile_authorization = pending.get("authorization", {}).duplicate(true)
@ -365,6 +410,7 @@ func confirm_profile_saved(request_id: String) -> void:
broadcast_profile_snapshot.rpc(
sender_id,
display_name,
player_title,
appearance,
pending.get("authorization", {}),
)
@ -405,10 +451,12 @@ func _apply_local_result(
_pending_speech_speed_ids.erase(request_id)
_pending_call_ids.erase(request_id)
_pending_sample_set_ids.erase(request_id)
_pending_titles.erase(request_id)
conflict_result.emit(request_id, conflict, suggestions)
apply_finished.emit(false, message)
return
var previous_name := _preferences.display_name
var previous_title := _preferences.title
var previous_voice_id := _preferences.voice_id
var previous_speech_speed_id := _preferences.speech_speed_id
var previous_call_id := _preferences.call_id
@ -429,18 +477,24 @@ func _apply_local_result(
request_id,
VoiceProfilesType.DEFAULT_SAMPLE_SET_ID,
)
var requested_title: String = _pending_titles.get(
request_id,
NetworkProfilePreferences.DEFAULT_TITLE,
)
if not _preferences.set_profile_identity(
str(request["display_name"]),
requested_voice_id,
requested_speech_speed_id,
requested_call_id,
requested_sample_set_id,
requested_title,
):
_pending_apply.erase(request_id)
_pending_voice_ids.erase(request_id)
_pending_speech_speed_ids.erase(request_id)
_pending_call_ids.erase(request_id)
_pending_sample_set_ids.erase(request_id)
_pending_titles.erase(request_id)
apply_finished.emit(false, "Profile could not be saved.")
return
if not _appearance_store.save_snapshot(request["appearance"]):
@ -450,12 +504,14 @@ func _apply_local_result(
previous_speech_speed_id,
previous_call_id,
previous_sample_set_id,
previous_title,
)
_pending_apply.erase(request_id)
_pending_voice_ids.erase(request_id)
_pending_speech_speed_ids.erase(request_id)
_pending_call_ids.erase(request_id)
_pending_sample_set_ids.erase(request_id)
_pending_titles.erase(request_id)
apply_finished.emit(false, "Profile could not be saved.")
return
_pending_apply.erase(request_id)
@ -463,6 +519,7 @@ func _apply_local_result(
_pending_speech_speed_ids.erase(request_id)
_pending_call_ids.erase(request_id)
_pending_sample_set_ids.erase(request_id)
_pending_titles.erase(request_id)
if _session != null:
_session.set_local_appearance_snapshot(_appearance_store.get_snapshot())
if _session != null and _session.is_gameplay_session_active():
@ -471,6 +528,7 @@ func _apply_local_result(
_session.get_local_peer_id(),
_preferences.display_name,
_appearance_store.get_snapshot(),
_preferences.title,
)
var record := _session.get_peer_record(
_session.get_local_peer_id()
@ -480,6 +538,7 @@ func _apply_local_result(
broadcast_profile_snapshot.rpc(
_session.get_local_peer_id(),
_preferences.display_name,
_preferences.title,
_appearance_store.get_snapshot(),
request,
)
@ -499,11 +558,13 @@ func _apply_local_result(
func broadcast_profile_snapshot(
peer_id: int,
display_name: String,
player_title: String,
appearance: Dictionary,
authorization: Dictionary = {},
) -> void:
if (
not NetworkProfilePreferences.is_valid_display_name(display_name)
or not NetworkProfilePreferences.is_valid_title(player_title)
or not CharacterCustomizationCatalog.validate_snapshot(appearance)
):
return
@ -514,12 +575,16 @@ func broadcast_profile_snapshot(
display_name,
appearance,
authorization,
player_title,
)
):
return
_session.apply_canonical_profile(peer_id, display_name, appearance)
_session.apply_canonical_profile(
peer_id, display_name, appearance, player_title
)
_apply_to_avatar(peer_id, appearance)
profile_snapshot_changed.emit(peer_id, display_name, appearance.duplicate(true))
profile_title_changed.emit(peer_id, player_title)
@rpc("authority", "call_remote", "reliable", NetworkProfileProtocol.RELIABLE_CHANNEL)
@ -533,7 +598,9 @@ func broadcast_appearance_preview(
if record == null:
return
var snapshot := CharacterCustomizationCatalog.sanitized_snapshot(appearance)
_session.apply_canonical_profile(peer_id, record.display_name, snapshot)
_session.apply_canonical_profile(
peer_id, record.display_name, snapshot, record.title
)
_apply_to_avatar(peer_id, snapshot)
profile_snapshot_changed.emit(
peer_id,
@ -575,6 +642,7 @@ func _on_peer_authenticated(peer_id: int, _display_name: String) -> void:
peer_id,
existing_id,
record.display_name,
record.title,
record.appearance_snapshot,
record.profile_authorization,
)
@ -638,6 +706,7 @@ func _on_session_state_changed(state: NetworkSession.State) -> void:
_pending_speech_speed_ids.clear()
_pending_call_ids.clear()
_pending_sample_set_ids.clear()
_pending_titles.clear()
_host_pending_apply.clear()
_latest_check_id = ""
_latest_check_name = ""

View file

@ -3,10 +3,11 @@ extends RefCounted
const WorldLayoutType = preload("res://world/world_layout.gd")
const PROTOCOL_VERSION: int = 12
const PROTOCOL_VERSION: int = 13
const GAME_BUILD: String = "prealpha"
const MAX_GAME_VERSION_LENGTH: int = 64
const MAX_DISPLAY_NAME_LENGTH: int = 24
const MAX_PLAYER_TITLE_LENGTH: int = 24
const MAX_PROFILE_ID_LENGTH: int = 96
const MAX_NONCE_LENGTH: int = 96
const MAX_PUBLIC_KEY_LENGTH: int = 8192
@ -183,6 +184,7 @@ static func make_client_hello(
cosmetic_snapshot: Dictionary = {},
identity_fingerprint: String = "",
identity_signature: PackedByteArray = PackedByteArray(),
player_title: String = NetworkProfilePreferences.DEFAULT_TITLE,
) -> Dictionary:
return {
"protocol_version": PROTOCOL_VERSION,
@ -190,6 +192,7 @@ static func make_client_hello(
"game_build": GAME_BUILD,
"local_profile_id": profile_id,
"display_name": display_name,
"player_title": player_title,
"client_nonce": client_nonce,
"capability_flags": PackedStringArray([
FISH_QUALITY_CAPABILITY,
@ -224,6 +227,7 @@ static func validate_client_hello(data: Variant) -> String:
"game_build",
"local_profile_id",
"display_name",
"player_title",
"client_nonce",
"capability_flags",
"cosmetic_snapshot",
@ -246,6 +250,11 @@ static func validate_client_hello(data: Variant) -> String:
return "Profile ID is invalid."
if typeof(payload["display_name"]) != TYPE_STRING:
return "Display name is invalid."
if (
typeof(payload["player_title"]) != TYPE_STRING
or not NetworkProfilePreferences.is_valid_title(payload["player_title"])
):
return "Player title is invalid."
if typeof(payload["client_nonce"]) != TYPE_STRING:
return "Client nonce is invalid."
if typeof(payload["capability_flags"]) not in [
@ -295,6 +304,7 @@ static func client_profile_fields(data: Dictionary) -> Array:
str(data.get("identity_fingerprint", "")),
str(data.get("local_profile_id", "")),
str(data.get("display_name", "")),
str(data.get("player_title", "")),
]
result.append_array(
CharacterCustomizationCatalog.appearance_signature_values(appearance)

View file

@ -67,6 +67,7 @@ signal host_openness_changed(is_open: bool)
signal session_display_name_changed(display_name: String)
signal peer_count_changed(player_count: int, max_players: int)
signal peer_display_name_changed(peer_id: int, display_name: String)
signal peer_title_changed(peer_id: int, title: String)
signal peer_profile_changed(
peer_id: int,
display_name: String,
@ -354,6 +355,7 @@ func _register_player_host() -> void:
NetworkProtocol.HOME_CAPABILITY,
NetworkProtocol.HOME_UPGRADE_CAPABILITY,
]),
_profile.title,
)
_registry.update_appearance(1, _local_appearance_snapshot)
var host_profile_hello := NetworkProtocol.make_client_hello(
@ -362,6 +364,8 @@ func _register_player_host() -> void:
NetworkIdentityCrypto.secure_id(32),
_local_appearance_snapshot,
_player_identity.fingerprint,
PackedByteArray(),
_profile.title,
)
host_profile_hello["identity_signature"] = _player_identity.sign(
"handshake_client_profile",
@ -931,13 +935,16 @@ func apply_canonical_profile(
peer_id: int,
display_name: String,
appearance: Dictionary,
player_title: String = NetworkProfilePreferences.DEFAULT_TITLE,
) -> bool:
if (
not NetworkProfilePreferences.is_valid_display_name(display_name)
or not NetworkProfilePreferences.is_valid_title(player_title)
or not CharacterCustomizationCatalog.validate_snapshot(appearance)
):
return false
var name_changed := _registry.update_display_name(peer_id, display_name)
var title_changed: bool = _registry.update_title(peer_id, player_title)
var appearance_changed := _registry.update_appearance(peer_id, appearance)
if name_changed:
peer_display_name_changed.emit(peer_id, display_name)
@ -945,9 +952,11 @@ func apply_canonical_profile(
var avatar: Player = _spawn_service.get_avatar(peer_id)
if avatar != null:
avatar.apply_appearance_snapshot(appearance)
if title_changed:
peer_title_changed.emit(peer_id, player_title)
if name_changed or appearance_changed:
peer_profile_changed.emit(peer_id, display_name, appearance.duplicate(true))
return name_changed or appearance_changed
return name_changed or title_changed or appearance_changed
@rpc("any_peer", "call_remote", "reliable", 0)
@ -1376,6 +1385,8 @@ func request_client_profile() -> void:
_client_nonce,
_local_appearance_snapshot,
_player_identity.fingerprint,
PackedByteArray(),
_profile.title,
)
hello["identity_signature"] = _player_identity.sign(
"handshake_client_profile",
@ -1459,6 +1470,7 @@ func submit_client_hello(data: Dictionary) -> void:
return
var profile_id: String = data["local_profile_id"]
var display_name: String = data["display_name"]
var player_title: String = data["player_title"]
if not NetworkProfilePreferences.is_valid_display_name(display_name):
_reject_peer(
sender_id,
@ -1473,6 +1485,7 @@ func submit_client_hello(data: Dictionary) -> void:
identity["fingerprint"],
identity["public_key"],
client_capabilities,
player_title,
):
_reject_peer(
sender_id,
@ -1515,6 +1528,7 @@ func submit_client_hello(data: Dictionary) -> void:
display_name,
submitted_appearance.duplicate(true),
)
peer_title_changed.emit(sender_id, player_title)
receive_server_hello.rpc_id(
sender_id,
NetworkProtocol.make_server_hello(
@ -1691,6 +1705,7 @@ func receive_server_hello(data: Dictionary) -> void:
NetworkProtocol.HOME_CAPABILITY,
NetworkProtocol.HOME_UPGRADE_CAPABILITY,
]),
_profile.title,
)
_registry.update_appearance(local_peer_id, _local_appearance_snapshot)
var local_record := _registry.get_peer(local_peer_id)
@ -1777,6 +1792,10 @@ func _apply_spawn_entry(entry: Dictionary) -> void:
typeof(entry.get("peer_id")) != TYPE_INT
or typeof(entry.get("profile_id")) != TYPE_STRING
or typeof(entry.get("display_name")) != TYPE_STRING
or typeof(entry.get("player_title")) != TYPE_STRING
or not NetworkProfilePreferences.is_valid_title(
str(entry["player_title"])
)
or typeof(entry.get("position")) != TYPE_ARRAY
or typeof(entry.get("yaw")) not in [TYPE_FLOAT, TYPE_INT]
or typeof(entry.get("sitting")) != TYPE_BOOL
@ -1811,6 +1830,7 @@ func _apply_spawn_entry(entry: Dictionary) -> void:
str(entry.get("identity_fingerprint", "")),
str(entry.get("identity_public_key", "")),
_sanitized_capabilities(entry.get("capability_flags", [])),
str(entry["player_title"]),
):
return
peer_was_added = true
@ -1894,6 +1914,10 @@ func _make_spawn_entry(
"peer_id": peer_id,
"profile_id": record.profile_id if record != null else "",
"display_name": display_name,
"player_title": (
record.title
if record != null else NetworkProfilePreferences.DEFAULT_TITLE
),
"position": [
transform.origin.x,
transform.origin.y,
@ -1978,6 +2002,7 @@ func verify_profile_authorization(
display_name: String,
appearance: Dictionary,
authorization: Dictionary,
player_title: String = NetworkProfilePreferences.DEFAULT_TITLE,
) -> bool:
var record := _registry.get_peer(peer_id)
if record == null:
@ -1986,6 +2011,7 @@ func verify_profile_authorization(
peer_id,
record.profile_id,
display_name,
player_title,
appearance,
record.identity_fingerprint,
record.identity_public_key,
@ -2000,6 +2026,7 @@ func _verify_spawn_identity(entry: Dictionary) -> bool:
int(entry.get("peer_id", 0)),
str(entry.get("profile_id", "")),
str(entry.get("display_name", "")),
str(entry.get("player_title", NetworkProfilePreferences.DEFAULT_TITLE)),
Dictionary(entry.get("appearance", {})),
str(entry.get("identity_fingerprint", "")),
str(entry.get("identity_public_key", "")),
@ -2011,6 +2038,7 @@ func _verify_profile_authorization_fields(
peer_id: int,
profile_id: String,
display_name: String,
player_title: String,
appearance: Dictionary,
fingerprint: String,
identity_public_key: String,
@ -2021,6 +2049,8 @@ func _verify_profile_authorization_fields(
)
if NetworkIdentityCrypto.fingerprint_public_pem(public_pem) != fingerprint:
return false
if not NetworkProfilePreferences.is_valid_title(player_title):
return false
if authorization.is_empty():
return peer_id == 1
if authorization.get("domain") == "handshake_client_profile":
@ -2031,6 +2061,7 @@ func _verify_profile_authorization_fields(
appearance,
fingerprint,
authorization.get("signature", PackedByteArray()),
player_title,
)
return NetworkIdentityCrypto.verify_fields(
NetworkIdentityCrypto.load_public_key(public_pem),
@ -2054,6 +2085,7 @@ func _verify_profile_authorization_fields(
return false
var signed := authorization.duplicate(true)
signed["display_name"] = display_name
signed["player_title"] = player_title
signed["appearance"] = appearance
return NetworkIdentityCrypto.verify_fields(
NetworkIdentityCrypto.load_public_key(public_pem),
@ -2182,7 +2214,6 @@ func _maybe_send_local_animation_action() -> void:
str(action["id"]),
int(action["sequence"]),
bool(action.get("paused", false)),
avatar.get_network_sitting_intent(),
]
if signature == _last_local_animation_action_signature:
return
@ -2237,9 +2268,9 @@ func submit_movement_animation_action(encoded: Array) -> void:
avatar.apply_authoritative_network_animation_action(
animation_state["action"]
)
avatar.apply_authoritative_network_sitting_state(
bool(animation_state["sitting"])
)
# Sitting is authoritative only on sequenced movement input. The legacy bit
# remains in this packet's wire shape for protocol compatibility, but acting
# on it here would race jump input across two independent ENet channels.
static func _encode_movement_input(data: Dictionary) -> Array:

View file

@ -7,6 +7,7 @@ class PeerRecord:
var peer_id: int = 0
var profile_id: String = ""
var display_name: String = ""
var title: String = NetworkProfilePreferences.DEFAULT_TITLE
var protocol_version: int = 0
var joined_at_unix: int = 0
var appearance_snapshot: Dictionary = (
@ -30,10 +31,12 @@ func add_peer(
identity_fingerprint: String = "",
identity_public_key: String = "",
capability_flags: PackedStringArray = PackedStringArray(),
player_title: String = NetworkProfilePreferences.DEFAULT_TITLE,
) -> bool:
if (
peer_id <= 0
or profile_id.is_empty()
or not NetworkProfilePreferences.is_valid_title(player_title)
or (
not identity_fingerprint.is_empty()
and has_fingerprint(identity_fingerprint)
@ -44,6 +47,7 @@ func add_peer(
record.peer_id = peer_id
record.profile_id = profile_id
record.display_name = display_name
record.title = player_title
record.protocol_version = protocol_version
record.joined_at_unix = int(Time.get_unix_time_from_system())
record.identity_fingerprint = identity_fingerprint
@ -74,6 +78,14 @@ func update_display_name(peer_id: int, display_name: String) -> bool:
return true
func update_title(peer_id: int, player_title: String) -> bool:
var record: PeerRecord = _records.get(peer_id)
if record == null or not NetworkProfilePreferences.is_valid_title(player_title):
return false
record.title = player_title.strip_edges()
return true
func update_appearance(peer_id: int, snapshot: Dictionary) -> bool:
var record: PeerRecord = _records.get(peer_id)
if record == null or not CharacterCustomizationCatalog.validate_snapshot(snapshot):

View file

@ -16,6 +16,10 @@ const ENVIRONMENT_VARIABLE := "straywild_DATA_DIR"
const CREATE_ENVIRONMENT_VARIABLE := "straywild_CREATE_DATA_DIR"
const LEGACY_ENVIRONMENT_VARIABLE := "NETFISHING_DATA_DIR"
const LEGACY_CREATE_ENVIRONMENT_VARIABLE := "NETFISHING_CREATE_DATA_DIR"
const ISOLATED_VALIDATION_ENVIRONMENT := (
"straywild_ISOLATED_VALIDATION_STORAGE"
)
const ISOLATED_VALIDATION_DIRECTORY := "isolated-validation-data"
const APPLICATION_ID := "straywild"
const LEGACY_APPLICATION_ID := "netfishing"
const APP_DATA_PORTABLE_PATH := "user://portable-data"
@ -57,6 +61,9 @@ enum Mode {
COMMAND_LINE_OVERRIDE,
}
static var _process_validation_root := ""
static var _process_validation_device_id := ""
var mode := Mode.UNRESOLVED
var root_path := ""
var root_id := ""
@ -67,6 +74,11 @@ var override_active := false
func resolve() -> bool:
# Validation scripts may be launched directly instead of through the
# repository runner. Keep the opt-in flag fail-safe: it must never follow a
# real user's bootstrap pointer merely because XDG isolation was omitted.
if _isolated_validation_requested():
return _resolve_isolated_validation_root()
prepare_legacy_user_data()
_load_bootstrap_identity()
var command_line: String = _command_line_override()
@ -116,6 +128,44 @@ func resolve() -> bool:
return false
func _resolve_isolated_validation_root() -> bool:
# Behave like app-data storage after choosing the isolated root so migration
# tests can still exercise live root switching within their sandbox.
override_active = false
mode = Mode.APP_DATA
if _process_validation_device_id.is_empty():
_process_validation_device_id = (
Crypto.new().generate_random_bytes(16).hex_encode()
)
device_id = _process_validation_device_id
if _process_validation_root.is_empty():
var parent: String
if OS.has_environment("XDG_DATA_HOME"):
parent = ProjectSettings.globalize_path("user://")
else:
parent = OS.get_temp_dir().path_join(
"straywild-validation-%d-%d"
% [OS.get_process_id(), Time.get_ticks_usec()]
)
_process_validation_root = parent.path_join(
ISOLATED_VALIDATION_DIRECTORY
)
if not has_data_manifest(_process_validation_root):
var created: Dictionary = create_unbound_root(
_process_validation_root
)
if not bool(created.get("ok", false)):
return false
return _activate_existing(_process_validation_root, "", false)
static func _isolated_validation_requested() -> bool:
return (
DisplayServer.get_name() == "headless"
and OS.get_environment(ISOLATED_VALIDATION_ENVIRONMENT) == "1"
)
func prepare_legacy_user_data() -> void:
var current_root: String = _normalize(
ProjectSettings.globalize_path("user://")