Expand gameplay systems and interface controls

This commit is contained in:
Alexander Sellite 2026-08-20 17:53:22 -04:00
parent 09b7a78533
commit 2710544070
46 changed files with 2072 additions and 153 deletions

View file

@ -32,8 +32,11 @@ const WorldWeatherServiceType = preload(
"res://world/world_weather_service.gd"
)
const PlayerJobServiceType = preload("res://jobs/player_job_service.gd")
const PlayerType = preload("res://player/player.gd")
const SAVE_VERSION: int = 9
const SAVE_VERSION: int = 10
const LEGACY_SAVE_FILENAME := "player_save.json"
const ARCHIVE_EXTENSION := ".nfsave"
const BASIC_ROD_ID: StringName = &"basic_fishing_rod"
const MAX_SAFE_BALANCE: int = 1000000000000
const DEFAULT_WORLD_SEED: int = 13001
@ -49,6 +52,8 @@ class LoadSnapshot:
var next_catch_sequence: int = 1
var bag_items: Array[OwnedItemType] = []
var unlocked_bait_ids: Array[StringName] = []
var active_bait_id: StringName
var active_lure_id: StringName
var hotbar_slots: Array[StringName] = []
var fish_hotbar_slots: Array[StringName] = []
var selected_hotbar_slot: int = 0
@ -87,6 +92,7 @@ var _experience: PlayerExperienceType
var _world_time: WorldTimeServiceType
var _world_weather: WorldWeatherServiceType
var _jobs: PlayerJobServiceType
var _player: PlayerType
var _autosave_timer: Timer
var _is_configured: bool = false
var _is_restoring: bool = false
@ -112,6 +118,21 @@ func _backup_path() -> String:
return _save_path + ".backup"
func _codec_scratch_path() -> String:
return _save_path + ".codec.tmp"
func _legacy_save_path() -> String:
return _save_path.get_base_dir().path_join(LEGACY_SAVE_FILENAME)
func _read_path() -> String:
if FileAccess.file_exists(_save_path):
return _save_path
var legacy: String = _legacy_save_path()
return legacy if FileAccess.file_exists(legacy) else ""
func _ready() -> void:
_autosave_timer = Timer.new()
_autosave_timer.one_shot = true
@ -135,6 +156,7 @@ func setup(
world_time: WorldTimeServiceType,
world_weather: WorldWeatherServiceType,
jobs: PlayerJobServiceType,
player: PlayerType,
) -> void:
_inventory = inventory
_collection_log = collection_log
@ -151,6 +173,7 @@ func setup(
_world_time = world_time
_world_weather = world_weather
_jobs = jobs
_player = player
_is_configured = (
_inventory != null
and _collection_log != null
@ -167,6 +190,7 @@ func setup(
and _world_time != null
and _world_weather != null
and _jobs != null
and _player != null
)
if not _is_configured:
push_error("PlayerSaveManager setup is missing required references.")
@ -209,6 +233,10 @@ func setup(
_experience.experience_changed.connect(_on_experience_changed)
if not _jobs.changed.is_connected(_mark_dirty):
_jobs.changed.connect(_mark_dirty)
if not _player.active_bait_changed.is_connected(_on_active_tackle_changed):
_player.active_bait_changed.connect(_on_active_tackle_changed)
if not _player.active_lure_changed.is_connected(_on_active_tackle_changed):
_player.active_lure_changed.connect(_on_active_tackle_changed)
func load_player_data() -> bool:
@ -216,24 +244,22 @@ func load_player_data() -> bool:
return false
_automatic_saving_blocked = false
_recover_interrupted_write()
if _save_path.is_empty() or not FileAccess.file_exists(_save_path):
var read_path: String = _read_path()
if _save_path.is_empty() or read_path.is_empty():
_is_dirty = false
return true
var save_file := FileAccess.open(_save_path, FileAccess.READ)
if save_file == null:
_handle_corrupt_save("Unable to open player save.")
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(
read_path,
read_path == _legacy_save_path(),
)
if not bool(decoded.get("ok", false)):
_handle_corrupt_save("Player save could not be decoded.", read_path)
return false
var json_text: String = save_file.get_as_text()
save_file.close()
_expected_hash = PortableFileGuard.hash_file(_save_path)
var json := JSON.new()
var parse_error: Error = json.parse(json_text)
if parse_error != OK or typeof(json.data) != TYPE_DICTIONARY:
_handle_corrupt_save("Player save contains malformed JSON.")
return false
var save_data: Dictionary = json.data
_expected_hash = (
PortableFileGuard.hash_file(_save_path)
if read_path == _save_path else ""
)
var save_data: Dictionary = decoded["data"]
var version: int = _read_integer(save_data.get("save_version"), -1)
if version > SAVE_VERSION:
_automatic_saving_blocked = true
@ -250,13 +276,16 @@ func load_player_data() -> bool:
save_data = _migrate_save(save_data, version)
if save_data.is_empty():
_handle_corrupt_save(
"Player save version %d is unsupported." % version
"Player save version %d is unsupported." % version,
read_path,
)
return false
var snapshot: LoadSnapshot = _build_load_snapshot(save_data)
if snapshot == null:
_handle_corrupt_save("Player save failed structural validation.")
_handle_corrupt_save(
"Player save failed structural validation.", read_path
)
return false
_is_restoring = true
@ -277,6 +306,7 @@ func load_player_data() -> bool:
_bag.replace_all_items(snapshot.bag_items)
and _bag.replace_unlocked_bait_ids(snapshot.unlocked_bait_ids)
)
var tackle_restored: bool = _restore_tackle_selection(snapshot)
var upgrades_restored: bool = _fishing_upgrades.restore_levels(
snapshot.reel_speed_level,
snapshot.barrier_power_level
@ -316,6 +346,7 @@ func load_player_data() -> bool:
or not collection_restored
or not wallet_restored
or not bag_restored
or not tackle_restored
or not hotbar_restored
or not upgrades_restored
or not cooler_restored
@ -329,7 +360,7 @@ func load_player_data() -> bool:
push_error(
(
"Validated player save could not be restored: "
+ "inventory=%s collection=%s wallet=%s bag=%s hotbar=%s "
+ "inventory=%s collection=%s wallet=%s bag=%s tackle=%s hotbar=%s "
+ "upgrades=%s cooler=%s layout=%s art=%s experience=%s "
+ "time=%s weather=%s jobs=%s"
)
@ -338,6 +369,7 @@ func load_player_data() -> bool:
collection_restored,
wallet_restored,
bag_restored,
tackle_restored,
hotbar_restored,
upgrades_restored,
cooler_restored,
@ -352,6 +384,8 @@ func load_player_data() -> bool:
return false
_is_dirty = false
if read_path == _legacy_save_path():
_migrate_legacy_plaintext_save(save_data, read_path)
print(
"Loaded player save version %d with %d catches."
% [SAVE_VERSION, snapshot.catches.size()]
@ -362,24 +396,21 @@ func load_player_data() -> bool:
func inspect_save() -> SaveInspectionType:
var result := SaveInspectionType.new()
_recover_interrupted_write()
result.has_primary_file = FileAccess.file_exists(_save_path)
var read_path: String = _read_path()
result.has_primary_file = not read_path.is_empty()
if not result.has_primary_file:
result.status = SaveInspectionType.Status.MISSING
result.message = "no save found."
return result
var save_file := FileAccess.open(_save_path, FileAccess.READ)
if save_file == null:
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(
read_path,
read_path == _legacy_save_path(),
)
if not bool(decoded.get("ok", false)):
result.status = SaveInspectionType.Status.IO_ERROR
result.message = "the save could not be read."
return result
var json := JSON.new()
var parse_error: Error = json.parse(save_file.get_as_text())
save_file.close()
if parse_error != OK or typeof(json.data) != TYPE_DICTIONARY:
result.status = SaveInspectionType.Status.MALFORMED
result.message = "the save is corrupt and was preserved."
return result
var save_data: Dictionary = json.data
var save_data: Dictionary = decoded["data"]
result.detected_version = _read_integer(save_data.get("save_version"), -1)
if result.detected_version > SAVE_VERSION:
result.status = SaveInspectionType.Status.UNSUPPORTED_VERSION
@ -408,6 +439,88 @@ func inspect_save() -> SaveInspectionType:
return result
func export_progression_archive(path: String) -> Dictionary:
if not _is_configured or path.is_empty():
return {"ok": false, "message": "progression export is unavailable."}
if _is_dirty and not save_if_dirty():
return {"ok": false, "message": "progression could not be saved first."}
var source_path: String = _read_path()
if source_path.is_empty():
return {"ok": false, "message": "there is no progression to export."}
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(
source_path,
source_path == _legacy_save_path(),
)
if not bool(decoded.get("ok", false)):
return {"ok": false, "message": "the active progression could not be read."}
var prepared: Dictionary = _prepare_external_save_data(decoded["data"])
if not bool(prepared.get("ok", false)):
return prepared
var bytes: PackedByteArray = ProgressionSaveCodec.encode_archive(
prepared["data"],
path + ".codec.tmp",
)
if bytes.is_empty():
return {"ok": false, "message": "the progression archive could not be encoded."}
var result: Dictionary = PortableFileGuard.write_guarded(
path,
bytes,
PortableFileGuard.hash_file(path),
_data_root.conflict_directory(),
_data_root.device_id,
)
if not bool(result.get("ok", false)):
return {"ok": false, "message": "the progression archive could not be written."}
var verified: Dictionary = inspect_progression_archive(path)
if not bool(verified.get("ok", false)):
return {"ok": false, "message": "the progression archive could not be verified."}
verified["message"] = "progression archive created."
return verified
func inspect_progression_archive(path: String) -> Dictionary:
var decoded: Dictionary = ProgressionSaveCodec.read_archive(path)
if not bool(decoded.get("ok", false)):
return {
"ok": false,
"message": str(decoded.get("message", "could not open progression archive.")),
}
var prepared: Dictionary = _prepare_external_save_data(decoded["data"])
if not bool(prepared.get("ok", false)):
return prepared
prepared["game_version"] = str(decoded.get("game_version", ""))
prepared["created_at_unix"] = int(decoded.get("created_at_unix", 0))
return prepared
func import_progression_archive(path: String) -> Dictionary:
if not _is_configured:
return {"ok": false, "message": "progression import is unavailable."}
var inspected: Dictionary = inspect_progression_archive(path)
if not bool(inspected.get("ok", false)):
return inspected
var current_path: String = _read_path()
if not current_path.is_empty() and not _archive_replaced_save(current_path):
return {"ok": false, "message": "the current progression could not be backed up."}
var expected_hash: String = PortableFileGuard.hash_file(_save_path)
var result: Dictionary = _write_current_save_data(
inspected["data"], expected_hash
)
if not bool(result.get("ok", false)):
return {"ok": false, "message": "the imported progression could not be installed."}
_expected_hash = str(result.get("hash", ""))
_remove_legacy_save_files()
_is_dirty = false
_automatic_saving_blocked = false
return {
"ok": true,
"message": "progression imported.",
"catch_count": inspected["catch_count"],
"wallet_balance": inspected["wallet_balance"],
"discovered_species_count": inspected["discovered_species_count"],
}
func initialize_new_game(world_seed: int = DEFAULT_WORLD_SEED) -> bool:
if not _is_configured:
return false
@ -431,9 +544,19 @@ static func roll_world_seed() -> int:
func delete_progression_save() -> bool:
if FileAccess.file_exists(_save_path) and not _remove_if_present(_save_path):
return false
for path: String in [_temp_path(), _backup_path()]:
for primary_path: String in [_save_path, _legacy_save_path()]:
if (
FileAccess.file_exists(primary_path)
and not _remove_if_present(primary_path)
):
return false
for path: String in [
_temp_path(),
_backup_path(),
_codec_scratch_path(),
_legacy_save_path() + ".tmp",
_legacy_save_path() + ".backup",
]:
if FileAccess.file_exists(path) and not _remove_if_present(path):
push_warning("Unable to remove stale player-save auxiliary file.")
if _autosave_timer != null:
@ -485,16 +608,8 @@ func save_now() -> bool:
var save_data: Dictionary = _build_save_dictionary()
if save_data.is_empty():
return false
var json_text: String = JSON.stringify(save_data, "\t")
if json_text.is_empty():
return false
var result := PortableFileGuard.write_guarded(
_save_path,
json_text.to_utf8_buffer(),
_expected_hash,
_data_root.conflict_directory(),
_data_root.device_id,
var result: Dictionary = _write_current_save_data(
save_data, _expected_hash
)
if bool(result.get("conflict", false)):
_data_root.report_conflict(
@ -574,6 +689,17 @@ func _build_save_dictionary() -> Dictionary:
)
):
return {}
var active_bait_id: StringName = _player.active_bait_id
var active_lure_id: StringName = _player.active_lure_id
if (
(not active_bait_id.is_empty() and not _valid_active_tackle(
active_bait_id, true
))
or (not active_lure_id.is_empty() and not _valid_active_tackle(
active_lure_id, false
))
):
return {}
return {
"save_version": SAVE_VERSION,
@ -592,6 +718,10 @@ func _build_save_dictionary() -> Dictionary:
"items": serialized_items,
"unlocked_bait_ids": serialized_unlocked_baits,
},
"tackle": {
"active_bait_id": String(active_bait_id),
"active_lure_id": String(active_lure_id),
},
"hotbar": {
"selected_slot": _hotbar.get_selected_slot(),
"slots": serialized_slots,
@ -620,6 +750,7 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
or typeof(save_data.get("collection")) != TYPE_DICTIONARY
or typeof(save_data.get("inventory")) != TYPE_DICTIONARY
or typeof(save_data.get("bag")) != TYPE_DICTIONARY
or typeof(save_data.get("tackle")) != TYPE_DICTIONARY
or typeof(save_data.get("hotbar")) != TYPE_DICTIONARY
or typeof(save_data.get("inventory_layout")) != TYPE_DICTIONARY
or typeof(save_data.get("experience")) != TYPE_DICTIONARY
@ -630,6 +761,7 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
var collection_data: Dictionary = save_data["collection"]
var inventory_data: Dictionary = save_data["inventory"]
var bag_data: Dictionary = save_data["bag"]
var tackle_data: Dictionary = save_data["tackle"]
var hotbar_data: Dictionary = save_data["hotbar"]
var inventory_layout_data: Dictionary = save_data["inventory_layout"]
var experience_data: Dictionary = save_data["experience"]
@ -665,6 +797,12 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
and typeof(bag_data.get("unlocked_bait_ids")) != TYPE_ARRAY
)
or typeof(hotbar_data.get("slots")) != TYPE_ARRAY
or typeof(tackle_data.get("active_bait_id")) not in [
TYPE_STRING, TYPE_STRING_NAME,
]
or typeof(tackle_data.get("active_lure_id")) not in [
TYPE_STRING, TYPE_STRING_NAME,
]
or not hotbar_data.has("selected_slot")
or not experience_data.has("total_experience")
):
@ -897,6 +1035,16 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
):
seen_unlocked_baits[owned.item_id] = true
snapshot.unlocked_bait_ids.append(owned.item_id)
snapshot.active_bait_id = _validated_tackle_id(
StringName(str(tackle_data.get("active_bait_id", ""))),
true,
seen_items,
)
snapshot.active_lure_id = _validated_tackle_id(
StringName(str(tackle_data.get("active_lure_id", ""))),
false,
seen_items,
)
var slot_values: Array = hotbar_data["slots"]
snapshot.hotbar_slots.resize(PlayerHotbarType.SLOT_COUNT)
@ -995,6 +1143,8 @@ func _migrate_save(
migrated = _migrate_version_7_to_8(migrated)
8:
migrated = _migrate_version_8_to_9(migrated)
9:
migrated = _migrate_version_9_to_10(migrated)
_:
return {}
if migrated.is_empty():
@ -1242,6 +1392,16 @@ func _migrate_version_8_to_9(data: Dictionary) -> Dictionary:
return migrated
func _migrate_version_9_to_10(data: Dictionary) -> Dictionary:
var migrated: Dictionary = data.duplicate(true)
migrated["tackle"] = {
"active_bait_id": "",
"active_lure_id": "",
}
migrated["save_version"] = 10
return migrated
func _mark_dirty() -> void:
if (
_is_restoring
@ -1291,33 +1451,168 @@ func _on_experience_changed(_total_experience: int, _level: int) -> void:
_mark_dirty()
func _on_active_tackle_changed(_item_id: StringName) -> void:
_mark_dirty()
func _on_autosave_timeout() -> void:
if _is_dirty:
save_now()
func _recover_interrupted_write() -> void:
if FileAccess.file_exists(_save_path):
_remove_if_present(_temp_path())
_remove_if_present(_backup_path())
_remove_if_present(_codec_scratch_path())
_recover_path_write(_save_path)
_recover_path_write(_legacy_save_path())
func _recover_path_write(path: String) -> void:
var temporary: String = path + ".tmp"
var backup: String = path + ".backup"
if FileAccess.file_exists(path):
_remove_if_present(temporary)
_remove_if_present(backup)
return
if FileAccess.file_exists(_backup_path()):
_rename_file(_backup_path(), _save_path)
_remove_if_present(_temp_path())
if FileAccess.file_exists(backup):
_rename_file(backup, path)
_remove_if_present(temporary)
func _handle_corrupt_save(message: String) -> void:
func _write_current_save_data(
save_data: Dictionary,
expected_hash: String,
) -> Dictionary:
var bytes: PackedByteArray = ProgressionSaveCodec.encode_local_save(
save_data,
_codec_scratch_path(),
)
if bytes.is_empty():
return {"ok": false}
return PortableFileGuard.write_guarded(
_save_path,
bytes,
expected_hash,
_data_root.conflict_directory(),
_data_root.device_id,
)
func _prepare_external_save_data(value: Variant) -> Dictionary:
if typeof(value) != TYPE_DICTIONARY:
return {"ok": false, "message": "progression data is malformed."}
var save_data: Dictionary = (value as Dictionary).duplicate(true)
var version: int = _read_integer(save_data.get("save_version"), -1)
if version > SAVE_VERSION:
return {
"ok": false,
"message": "this progression belongs to a newer game version.",
}
if version < 1:
return {"ok": false, "message": "the progression version is unsupported."}
if version != SAVE_VERSION:
save_data = _migrate_save(save_data, version)
if save_data.is_empty():
return {
"ok": false,
"message": "the progression version is unsupported.",
}
var snapshot: LoadSnapshot = _build_load_snapshot(save_data)
if snapshot == null:
return {"ok": false, "message": "the progression is structurally invalid."}
return {
"ok": true,
"data": save_data,
"catch_count": snapshot.catches.size(),
"wallet_balance": snapshot.wallet_balance,
"discovered_species_count": snapshot.discovered_ids.size(),
"world_seed": snapshot.world_seed,
}
func _migrate_legacy_plaintext_save(
save_data: Dictionary,
legacy_path: String,
) -> void:
var result: Dictionary = _write_current_save_data(save_data, "")
if not bool(result.get("ok", false)):
push_warning("Legacy progression loaded but could not be encrypted yet.")
return
_expected_hash = str(result.get("hash", ""))
if not _archive_replaced_save(legacy_path, "plaintext-migration"):
push_warning(
"Legacy progression was encrypted, but its migration backup failed."
)
return
_remove_legacy_save_files()
print("Upgraded legacy plaintext progression to the opaque save format.")
func _archive_replaced_save(
source_path: String,
reason: String = "before-import",
) -> bool:
if source_path.is_empty() or not FileAccess.file_exists(source_path):
return true
var timestamp: String = Time.get_datetime_string_from_system().replace(
":", "-"
)
var destination: String = _data_root.root_path.path_join(
"backups/saves/player-save-%s-%s%s"
% [reason, timestamp, ARCHIVE_EXTENSION]
)
if FileAccess.file_exists(destination):
destination = destination.trim_suffix(ARCHIVE_EXTENSION) + (
"-%s%s" % [Time.get_ticks_usec(), ARCHIVE_EXTENSION]
)
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(
source_path,
source_path == _legacy_save_path(),
)
var bytes: PackedByteArray
if bool(decoded.get("ok", false)):
var prepared: Dictionary = _prepare_external_save_data(decoded["data"])
if bool(prepared.get("ok", false)):
bytes = ProgressionSaveCodec.encode_archive(
prepared["data"], destination + ".codec.tmp"
)
if bytes.is_empty():
bytes = PortableFileGuard.read_bytes(source_path)
destination = (
destination.trim_suffix(ARCHIVE_EXTENSION) + ".preserved"
)
if bytes.is_empty():
return false
var result: Dictionary = PortableFileGuard.write_guarded(
destination,
bytes,
"",
_data_root.conflict_directory(),
_data_root.device_id,
)
return bool(result.get("ok", false))
func _remove_legacy_save_files() -> void:
for path: String in [
_legacy_save_path(),
_legacy_save_path() + ".tmp",
_legacy_save_path() + ".backup",
]:
_remove_if_present(path)
func _handle_corrupt_save(message: String, source_path: String) -> void:
push_warning(message)
_restore_defaults()
if not FileAccess.file_exists(_save_path):
if not FileAccess.file_exists(source_path):
return
var timestamp: int = int(Time.get_unix_time_from_system())
var corrupt_path: String = (
"%s.corrupt-%d" % [_save_path, timestamp]
"%s.corrupt-%d" % [source_path, timestamp]
)
if FileAccess.file_exists(corrupt_path):
corrupt_path += "-%d" % Time.get_ticks_usec()
if not _rename_file(_save_path, corrupt_path):
if not _rename_file(source_path, corrupt_path):
_automatic_saving_blocked = true
push_warning(
"Corrupt player save was left in place; automatic saving is "
@ -1350,6 +1645,8 @@ func _restore_defaults() -> void:
_bag.replace_all_items(default_items)
var default_unlocked_baits: Array[StringName] = []
_bag.replace_unlocked_bait_ids(default_unlocked_baits)
_player.unequip_bait()
_player.unequip_lure()
_fishing_upgrades.reset_to_defaults()
_cooler_capacity.reset_to_defaults()
_inventory_layout.reset_to_defaults()
@ -1366,6 +1663,53 @@ func _restore_defaults() -> void:
_is_dirty = false
func _valid_active_tackle(item_id: StringName, bait: bool) -> bool:
var item = _item_catalog.get_item_by_id(item_id)
return (
item != null
and item.is_available()
and (item.is_bait() if bait else item.is_lure())
and _bag.owns_item(item_id)
)
func _validated_tackle_id(
item_id: StringName,
bait: bool,
seen_items: Dictionary[StringName, bool],
) -> StringName:
if item_id.is_empty():
return StringName()
var item = _item_catalog.get_item_by_id(item_id)
if (
item == null
or not item.is_available()
or not seen_items.has(item_id)
or not (item.is_bait() if bait else item.is_lure())
):
push_warning("Skipped invalid saved active tackle '%s'." % String(item_id))
return StringName()
return item_id
func _restore_tackle_selection(snapshot: LoadSnapshot) -> bool:
_player.unequip_bait()
_player.unequip_lure()
var bait_restored: bool = (
snapshot.active_bait_id.is_empty()
or _player.equip_bait(
_item_catalog.get_item_by_id(snapshot.active_bait_id)
)
)
var lure_restored: bool = (
snapshot.active_lure_id.is_empty()
or _player.equip_lure(
_item_catalog.get_item_by_id(snapshot.active_lure_id)
)
)
return bait_restored and lure_restored
func _read_integer(
value: Variant,
invalid_value: int,

View file

@ -0,0 +1,179 @@
class_name ProgressionSaveCodec
extends RefCounted
const MAGIC := "NETFISHING_PROGRESSION_CONTAINER"
const FORMAT_VERSION := 1
const LOCAL_KIND := "local_save"
const ARCHIVE_KIND := "portable_archive"
const MAX_CONTAINER_BYTES := 16 * 1024 * 1024
# This key keeps routine progression files opaque to casual editing. Since the
# client is open source, it is intentionally not treated as a security secret.
const CONTAINER_PASSPHRASE := (
"NETfishing progression container v1 / not an identity credential"
)
static func encode_local_save(
save_data: Dictionary,
scratch_path: String,
) -> PackedByteArray:
return _encode_container(LOCAL_KIND, save_data, scratch_path)
static func encode_archive(
save_data: Dictionary,
scratch_path: String,
) -> PackedByteArray:
return _encode_container(ARCHIVE_KIND, save_data, scratch_path)
static func read_local_save(
path: String,
allow_legacy_plaintext: bool = false,
) -> Dictionary:
if allow_legacy_plaintext and not _looks_encrypted(path):
var legacy: Dictionary = _read_plaintext_dictionary(path)
if bool(legacy.get("ok", false)):
legacy["legacy_plaintext"] = true
return legacy
return _read_container(path, LOCAL_KIND)
static func read_archive(path: String) -> Dictionary:
if not _looks_encrypted(path):
return {"ok": false, "message": "progression archive is not recognized."}
return _read_container(path, ARCHIVE_KIND)
static func _encode_container(
kind: String,
save_data: Dictionary,
scratch_path: String,
) -> PackedByteArray:
if save_data.is_empty() or scratch_path.is_empty():
return PackedByteArray()
var payload_json: String = JSON.stringify(save_data)
if payload_json.is_empty():
return PackedByteArray()
var payload_bytes: PackedByteArray = payload_json.to_utf8_buffer()
var envelope: Dictionary = {
"magic": MAGIC,
"format_version": FORMAT_VERSION,
"kind": kind,
"game_version": str(
ProjectSettings.get_setting("application/config/version", "")
),
"created_at_unix": int(Time.get_unix_time_from_system()),
"payload_sha256": PortableFileGuard.hash_bytes(payload_bytes),
"payload_json": payload_json,
}
if DirAccess.make_dir_recursive_absolute(
scratch_path.get_base_dir()
) != OK:
return PackedByteArray()
_remove_if_present(scratch_path)
var file: FileAccess = FileAccess.open_encrypted_with_pass(
scratch_path,
FileAccess.WRITE,
CONTAINER_PASSPHRASE,
)
if file == null:
return PackedByteArray()
file.store_string(JSON.stringify(envelope))
file.flush()
var write_ok: bool = file.get_error() == OK
file.close()
if not write_ok:
_remove_if_present(scratch_path)
return PackedByteArray()
var encoded: PackedByteArray = PortableFileGuard.read_bytes(
scratch_path,
MAX_CONTAINER_BYTES,
)
_remove_if_present(scratch_path)
return encoded
static func _read_container(path: String, expected_kind: String) -> Dictionary:
if (
path.is_empty()
or not FileAccess.file_exists(path)
or FileAccess.get_size(path) > MAX_CONTAINER_BYTES
):
return {"ok": false, "message": "progression file is unavailable."}
var file: FileAccess = FileAccess.open_encrypted_with_pass(
path,
FileAccess.READ,
CONTAINER_PASSPHRASE,
)
if file == null:
return {"ok": false, "message": "progression file could not be opened."}
var envelope_text: String = file.get_as_text()
file.close()
var envelope_json := JSON.new()
if (
envelope_json.parse(envelope_text) != OK
or typeof(envelope_json.data) != TYPE_DICTIONARY
):
return {"ok": false, "message": "progression container is malformed."}
var envelope: Dictionary = envelope_json.data
if (
envelope.get("magic") != MAGIC
or int(envelope.get("format_version", -1)) != FORMAT_VERSION
or str(envelope.get("kind", "")) != expected_kind
):
return {"ok": false, "message": "progression container is unsupported."}
var payload_json: String = str(envelope.get("payload_json", ""))
var payload_bytes: PackedByteArray = payload_json.to_utf8_buffer()
if (
payload_json.is_empty()
or str(envelope.get("payload_sha256", ""))
!= PortableFileGuard.hash_bytes(payload_bytes)
):
return {"ok": false, "message": "progression container failed validation."}
var payload_parser := JSON.new()
if (
payload_parser.parse(payload_json) != OK
or typeof(payload_parser.data) != TYPE_DICTIONARY
):
return {"ok": false, "message": "progression payload is malformed."}
return {
"ok": true,
"data": payload_parser.data,
"game_version": str(envelope.get("game_version", "")),
"created_at_unix": int(envelope.get("created_at_unix", 0)),
"legacy_plaintext": false,
}
static func _read_plaintext_dictionary(path: String) -> Dictionary:
var bytes: PackedByteArray = PortableFileGuard.read_bytes(
path,
MAX_CONTAINER_BYTES,
)
if bytes.is_empty():
return {"ok": false}
var text: String = bytes.get_string_from_utf8()
if not text.strip_edges().begins_with("{"):
return {"ok": false}
var parser := JSON.new()
if parser.parse(text) != OK or typeof(parser.data) != TYPE_DICTIONARY:
return {"ok": false}
return {"ok": true, "data": parser.data}
static func _looks_encrypted(path: String) -> bool:
if not FileAccess.file_exists(path) or FileAccess.get_size(path) < 4:
return false
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
return false
var header: PackedByteArray = file.get_buffer(4)
file.close()
return header == PackedByteArray([0x47, 0x44, 0x45, 0x43])
static func _remove_if_present(path: String) -> void:
if FileAccess.file_exists(path):
DirAccess.remove_absolute(path)

View file

@ -0,0 +1 @@
uid://lad8mvedktww