2026-07-29 15:01:46 -04:00
|
|
|
class_name NetworkProfilePreferences
|
|
|
|
|
extends Node
|
|
|
|
|
|
|
|
|
|
const FORMAT_VERSION: int = 1
|
2026-08-08 00:34:06 -04:00
|
|
|
const VoiceProfilesType = preload(
|
|
|
|
|
"res://player/animalese_voice_profiles.gd"
|
|
|
|
|
)
|
2026-07-29 15:01:46 -04:00
|
|
|
var profile_id: String = ""
|
|
|
|
|
var display_name: String = "Player"
|
2026-08-08 00:34:06 -04:00
|
|
|
var voice_id: String = VoiceProfilesType.DEFAULT_ID
|
|
|
|
|
var speech_speed_id: String = VoiceProfilesType.DEFAULT_SPEED_ID
|
2026-08-09 11:22:35 -04:00
|
|
|
var call_id: String = VoiceProfilesType.DEFAULT_CALL_ID
|
2026-07-29 15:01:46 -04:00
|
|
|
var created_at_unix: int = 0
|
2026-07-29 23:51:25 -04:00
|
|
|
var _profile_path := ""
|
|
|
|
|
var _expected_hash := ""
|
|
|
|
|
var _data_root: PlayerDataRoot
|
|
|
|
|
var _future_version := false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func configure_storage(path: String, data_root: PlayerDataRoot) -> void:
|
|
|
|
|
_profile_path = path
|
|
|
|
|
_data_root = data_root
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _temp_path() -> String:
|
|
|
|
|
return _profile_path + ".tmp"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _backup_path() -> String:
|
|
|
|
|
return _profile_path + ".backup"
|
2026-07-29 15:01:46 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
func load_or_create() -> bool:
|
2026-07-29 23:51:25 -04:00
|
|
|
if _profile_path.is_empty():
|
|
|
|
|
return false
|
2026-07-29 15:01:46 -04:00
|
|
|
_recover_interrupted_write()
|
2026-07-29 23:51:25 -04:00
|
|
|
if FileAccess.file_exists(_profile_path):
|
2026-07-29 15:01:46 -04:00
|
|
|
if _load_existing():
|
2026-07-29 23:51:25 -04:00
|
|
|
_expected_hash = PortableFileGuard.hash_file(_profile_path)
|
2026-07-29 15:01:46 -04:00
|
|
|
return true
|
2026-07-29 23:51:25 -04:00
|
|
|
if _future_version:
|
|
|
|
|
return false
|
2026-07-29 15:01:46 -04:00
|
|
|
var corrupt_path: String = (
|
2026-07-29 23:51:25 -04:00
|
|
|
"%s.corrupt.%d.json"
|
|
|
|
|
% [_profile_path.get_basename(), int(Time.get_unix_time_from_system())]
|
2026-07-29 15:01:46 -04:00
|
|
|
)
|
2026-07-29 23:51:25 -04:00
|
|
|
if not _rename(_profile_path, corrupt_path):
|
2026-07-29 15:01:46 -04:00
|
|
|
push_warning("Invalid network profile was preserved and not overwritten.")
|
|
|
|
|
return false
|
|
|
|
|
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 = "Player"
|
2026-08-08 00:34:06 -04:00
|
|
|
voice_id = VoiceProfilesType.DEFAULT_ID
|
|
|
|
|
speech_speed_id = VoiceProfilesType.DEFAULT_SPEED_ID
|
2026-08-09 11:22:35 -04:00
|
|
|
call_id = VoiceProfilesType.DEFAULT_CALL_ID
|
2026-07-29 15:01:46 -04:00
|
|
|
created_at_unix = int(Time.get_unix_time_from_system())
|
|
|
|
|
return _save_atomic()
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 19:26:46 -04:00
|
|
|
func set_display_name(value: String) -> bool:
|
2026-08-09 11:22:35 -04:00
|
|
|
return set_profile_identity(value, voice_id, speech_speed_id, call_id)
|
2026-08-08 00:34:06 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
func set_profile_identity(
|
|
|
|
|
value: String,
|
|
|
|
|
selected_voice_id: String,
|
|
|
|
|
selected_speech_speed_id: String,
|
2026-08-09 11:22:35 -04:00
|
|
|
selected_call_id: String,
|
2026-08-08 00:34:06 -04:00
|
|
|
) -> bool:
|
2026-07-29 19:26:46 -04:00
|
|
|
var clean_name: String = value.strip_edges()
|
2026-08-08 00:34:06 -04:00
|
|
|
if (
|
|
|
|
|
not is_valid_display_name(clean_name)
|
|
|
|
|
or not VoiceProfilesType.is_valid(selected_voice_id)
|
|
|
|
|
or not VoiceProfilesType.is_valid_speed(selected_speech_speed_id)
|
2026-08-09 11:22:35 -04:00
|
|
|
or not VoiceProfilesType.is_valid_call(selected_call_id)
|
2026-08-08 00:34:06 -04:00
|
|
|
):
|
2026-07-29 19:26:46 -04:00
|
|
|
return false
|
2026-08-08 00:34:06 -04:00
|
|
|
var previous_name: String = display_name
|
|
|
|
|
var previous_voice_id: String = voice_id
|
|
|
|
|
var previous_speech_speed_id: String = speech_speed_id
|
2026-08-09 11:22:35 -04:00
|
|
|
var previous_call_id: String = call_id
|
2026-07-29 19:26:46 -04:00
|
|
|
display_name = clean_name
|
2026-08-08 00:34:06 -04:00
|
|
|
voice_id = selected_voice_id
|
|
|
|
|
speech_speed_id = selected_speech_speed_id
|
2026-08-09 11:22:35 -04:00
|
|
|
call_id = selected_call_id
|
2026-07-29 19:26:46 -04:00
|
|
|
if _save_atomic():
|
|
|
|
|
return true
|
2026-08-08 00:34:06 -04:00
|
|
|
display_name = previous_name
|
|
|
|
|
voice_id = previous_voice_id
|
|
|
|
|
speech_speed_id = previous_speech_speed_id
|
2026-08-09 11:22:35 -04:00
|
|
|
call_id = previous_call_id
|
2026-07-29 19:26:46 -04:00
|
|
|
return false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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:
|
|
|
|
|
return false
|
|
|
|
|
for index: int in clean_name.length():
|
|
|
|
|
var codepoint: int = clean_name.unicode_at(index)
|
|
|
|
|
if codepoint < 32 or codepoint == 127:
|
|
|
|
|
return false
|
|
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
2026-07-29 15:01:46 -04:00
|
|
|
func _load_existing() -> bool:
|
2026-07-29 23:51:25 -04:00
|
|
|
var file := FileAccess.open(_profile_path, FileAccess.READ)
|
2026-07-29 15:01:46 -04:00
|
|
|
if file == null:
|
|
|
|
|
return false
|
|
|
|
|
var json := JSON.new()
|
|
|
|
|
var error: Error = json.parse(file.get_as_text())
|
|
|
|
|
file.close()
|
|
|
|
|
if error != OK or typeof(json.data) != TYPE_DICTIONARY:
|
|
|
|
|
return false
|
|
|
|
|
var data: Dictionary = json.data
|
2026-07-29 23:51:25 -04:00
|
|
|
if (
|
|
|
|
|
typeof(data.get("format_version")) in [TYPE_INT, TYPE_FLOAT]
|
|
|
|
|
and int(data.get("format_version")) > FORMAT_VERSION
|
|
|
|
|
):
|
|
|
|
|
_future_version = true
|
|
|
|
|
return false
|
2026-07-29 15:01:46 -04:00
|
|
|
if (
|
|
|
|
|
data.get("format_version") != FORMAT_VERSION
|
|
|
|
|
or typeof(data.get("profile_id")) != TYPE_STRING
|
|
|
|
|
or typeof(data.get("display_name")) != 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"]
|
|
|
|
|
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
|
|
|
|
|
):
|
|
|
|
|
return false
|
|
|
|
|
profile_id = loaded_id
|
|
|
|
|
display_name = loaded_name
|
2026-08-08 00:34:06 -04:00
|
|
|
voice_id = VoiceProfilesType.sanitized_id(
|
|
|
|
|
str(data.get("voice_id", VoiceProfilesType.DEFAULT_ID))
|
|
|
|
|
)
|
|
|
|
|
speech_speed_id = VoiceProfilesType.sanitized_speed_id(
|
|
|
|
|
str(data.get("speech_speed_id", VoiceProfilesType.DEFAULT_SPEED_ID))
|
|
|
|
|
)
|
2026-08-09 11:22:35 -04:00
|
|
|
call_id = VoiceProfilesType.sanitized_call_id(
|
|
|
|
|
str(data.get("call_id", VoiceProfilesType.DEFAULT_CALL_ID))
|
|
|
|
|
)
|
2026-07-29 15:01:46 -04:00
|
|
|
created_at_unix = int(data["created_at_unix"])
|
|
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _save_atomic() -> bool:
|
|
|
|
|
var data: Dictionary = {
|
|
|
|
|
"format_version": FORMAT_VERSION,
|
|
|
|
|
"profile_id": profile_id,
|
|
|
|
|
"display_name": display_name,
|
2026-08-08 00:34:06 -04:00
|
|
|
"voice_id": voice_id,
|
|
|
|
|
"speech_speed_id": speech_speed_id,
|
2026-08-09 11:22:35 -04:00
|
|
|
"call_id": call_id,
|
2026-07-29 15:01:46 -04:00
|
|
|
"created_at_unix": created_at_unix,
|
|
|
|
|
}
|
2026-07-29 23:51:25 -04:00
|
|
|
var result := PortableFileGuard.write_guarded(
|
|
|
|
|
_profile_path,
|
|
|
|
|
JSON.stringify(data, "\t").to_utf8_buffer(),
|
|
|
|
|
_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))
|
2026-07-29 15:01:46 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
func _recover_interrupted_write() -> void:
|
2026-07-29 23:51:25 -04:00
|
|
|
if FileAccess.file_exists(_profile_path):
|
|
|
|
|
_remove_if_present(_temp_path())
|
|
|
|
|
_remove_if_present(_backup_path())
|
2026-07-29 15:01:46 -04:00
|
|
|
return
|
2026-07-29 23:51:25 -04:00
|
|
|
if FileAccess.file_exists(_backup_path()):
|
|
|
|
|
_rename(_backup_path(), _profile_path)
|
|
|
|
|
_remove_if_present(_temp_path())
|
2026-07-29 15:01:46 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
func _rename(from_path: String, to_path: String) -> bool:
|
|
|
|
|
return DirAccess.rename_absolute(
|
|
|
|
|
ProjectSettings.globalize_path(from_path),
|
|
|
|
|
ProjectSettings.globalize_path(to_path)
|
|
|
|
|
) == OK
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _remove_if_present(path: String) -> bool:
|
|
|
|
|
return (
|
|
|
|
|
not FileAccess.file_exists(path)
|
|
|
|
|
or DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) == OK
|
|
|
|
|
)
|