2026-07-25 18:20:49 -04:00
|
|
|
class_name PlayerSaveManager
|
|
|
|
|
extends Node
|
|
|
|
|
|
|
|
|
|
const FishCatchType = preload("res://fish/fish_catch.gd")
|
2026-08-02 17:44:11 -04:00
|
|
|
const FishQualityType = preload("res://fish/fish_quality.gd")
|
2026-07-25 18:20:49 -04:00
|
|
|
const FishInventoryType = preload("res://inventory/fish_inventory.gd")
|
|
|
|
|
const CollectionLogType = preload("res://collection/collection_log.gd")
|
|
|
|
|
const PlayerWalletType = preload("res://economy/player_wallet.gd")
|
|
|
|
|
const FishPoolType = preload("res://fish/fish_pool.gd")
|
|
|
|
|
const SaveInspectionType = preload("res://save/player_save_inspection.gd")
|
2026-07-25 20:12:29 -04:00
|
|
|
const ItemCatalogType = preload("res://items/item_catalog.gd")
|
|
|
|
|
const OwnedItemType = preload("res://items/owned_item.gd")
|
|
|
|
|
const PlayerBagType = preload("res://inventory/player_bag.gd")
|
|
|
|
|
const PlayerHotbarType = preload("res://inventory/player_hotbar.gd")
|
2026-07-25 21:24:01 -04:00
|
|
|
const PlayerFishingUpgradesType = preload(
|
|
|
|
|
"res://progression/player_fishing_upgrades.gd"
|
|
|
|
|
)
|
2026-07-25 22:15:39 -04:00
|
|
|
const PlayerCoolerCapacityType = preload(
|
|
|
|
|
"res://progression/player_cooler_capacity.gd"
|
|
|
|
|
)
|
2026-08-02 00:45:07 -04:00
|
|
|
const PlayerArtUnlocksType = preload(
|
|
|
|
|
"res://progression/player_art_unlocks.gd"
|
|
|
|
|
)
|
2026-08-02 19:01:16 -04:00
|
|
|
const PlayerExperienceType = preload(
|
|
|
|
|
"res://progression/player_experience.gd"
|
|
|
|
|
)
|
|
|
|
|
const WorldTimeServiceType = preload("res://world/world_time_service.gd")
|
2026-08-03 00:43:43 -04:00
|
|
|
const WorldWeatherServiceType = preload(
|
|
|
|
|
"res://world/world_weather_service.gd"
|
|
|
|
|
)
|
|
|
|
|
const PlayerJobServiceType = preload("res://jobs/player_job_service.gd")
|
2026-07-25 18:20:49 -04:00
|
|
|
|
2026-08-03 00:43:43 -04:00
|
|
|
const SAVE_VERSION: int = 7
|
2026-07-25 20:12:29 -04:00
|
|
|
const BASIC_ROD_ID: StringName = &"basic_fishing_rod"
|
2026-07-25 18:20:49 -04:00
|
|
|
const MAX_SAFE_BALANCE: int = 1000000000000
|
|
|
|
|
|
|
|
|
|
class LoadSnapshot:
|
|
|
|
|
extends RefCounted
|
|
|
|
|
|
|
|
|
|
var catches: Array[FishCatchType] = []
|
|
|
|
|
var discovered_ids: Array[StringName] = []
|
2026-08-02 17:44:11 -04:00
|
|
|
var discovered_quality_masks: Dictionary[StringName, int] = {}
|
2026-07-25 18:20:49 -04:00
|
|
|
var wallet_balance: int = 0
|
|
|
|
|
var next_catch_sequence: int = 1
|
2026-07-25 20:12:29 -04:00
|
|
|
var bag_items: Array[OwnedItemType] = []
|
|
|
|
|
var hotbar_slots: Array[StringName] = []
|
2026-08-01 17:12:29 -04:00
|
|
|
var fish_hotbar_slots: Array[StringName] = []
|
2026-07-25 20:12:29 -04:00
|
|
|
var selected_hotbar_slot: int = 0
|
2026-07-25 21:24:01 -04:00
|
|
|
var reel_speed_level: int = 0
|
|
|
|
|
var barrier_power_level: int = 0
|
2026-07-25 22:15:39 -04:00
|
|
|
var cooler_capacity_level: int = 0
|
2026-08-02 00:45:07 -04:00
|
|
|
var art_unlock_mask: int = 0
|
2026-08-02 19:01:16 -04:00
|
|
|
var total_experience: int = 0
|
|
|
|
|
var world_time_hours: float = WorldTimeServiceType.DEFAULT_START_HOUR
|
2026-08-03 00:43:43 -04:00
|
|
|
var has_world_weather_state: bool = false
|
|
|
|
|
var world_weather: WorldWeatherServiceType.Weather = (
|
|
|
|
|
WorldWeatherServiceType.DEFAULT_WEATHER
|
|
|
|
|
)
|
|
|
|
|
var world_weather_seconds_remaining: float = (
|
|
|
|
|
WorldWeatherServiceType.SUNNY_DURATION_RANGE.x
|
|
|
|
|
)
|
|
|
|
|
var jobs_data: Dictionary = PlayerJobServiceType.default_save_data()
|
2026-07-25 18:20:49 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@export_range(0.05, 5.0, 0.05) var autosave_delay: float = 0.5
|
|
|
|
|
|
|
|
|
|
var _inventory: FishInventoryType
|
|
|
|
|
var _collection_log: CollectionLogType
|
|
|
|
|
var _wallet: PlayerWalletType
|
|
|
|
|
var _catalog: FishPoolType
|
2026-07-25 20:12:29 -04:00
|
|
|
var _bag: PlayerBagType
|
|
|
|
|
var _hotbar: PlayerHotbarType
|
|
|
|
|
var _item_catalog: ItemCatalogType
|
2026-07-25 21:24:01 -04:00
|
|
|
var _fishing_upgrades: PlayerFishingUpgradesType
|
2026-07-25 22:15:39 -04:00
|
|
|
var _cooler_capacity: PlayerCoolerCapacityType
|
2026-08-02 00:45:07 -04:00
|
|
|
var _art_unlocks: PlayerArtUnlocksType
|
2026-08-02 19:01:16 -04:00
|
|
|
var _experience: PlayerExperienceType
|
|
|
|
|
var _world_time: WorldTimeServiceType
|
2026-08-03 00:43:43 -04:00
|
|
|
var _world_weather: WorldWeatherServiceType
|
|
|
|
|
var _jobs: PlayerJobServiceType
|
2026-07-25 18:20:49 -04:00
|
|
|
var _autosave_timer: Timer
|
|
|
|
|
var _is_configured: bool = false
|
|
|
|
|
var _is_restoring: bool = false
|
|
|
|
|
var _is_dirty: bool = false
|
|
|
|
|
var _automatic_saving_blocked: bool = false
|
|
|
|
|
var _autosave_enabled: bool = false
|
2026-07-29 23:51:25 -04:00
|
|
|
var _save_path := ""
|
|
|
|
|
var _expected_hash := ""
|
|
|
|
|
var _data_root: PlayerDataRoot
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func configure_storage(path: String, data_root: PlayerDataRoot) -> void:
|
|
|
|
|
_save_path = path
|
|
|
|
|
_data_root = data_root
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _temp_path() -> String:
|
|
|
|
|
return _save_path + ".tmp"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _backup_path() -> String:
|
|
|
|
|
return _save_path + ".backup"
|
2026-07-25 18:20:49 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
func _ready() -> void:
|
|
|
|
|
_autosave_timer = Timer.new()
|
|
|
|
|
_autosave_timer.one_shot = true
|
|
|
|
|
_autosave_timer.timeout.connect(_on_autosave_timeout)
|
|
|
|
|
add_child(_autosave_timer)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func setup(
|
|
|
|
|
inventory: FishInventoryType,
|
|
|
|
|
collection_log: CollectionLogType,
|
|
|
|
|
wallet: PlayerWalletType,
|
|
|
|
|
catalog: FishPoolType,
|
2026-07-25 20:12:29 -04:00
|
|
|
bag: PlayerBagType,
|
|
|
|
|
hotbar: PlayerHotbarType,
|
|
|
|
|
item_catalog: ItemCatalogType,
|
2026-07-25 21:24:01 -04:00
|
|
|
fishing_upgrades: PlayerFishingUpgradesType,
|
2026-07-25 22:15:39 -04:00
|
|
|
cooler_capacity: PlayerCoolerCapacityType,
|
2026-08-02 00:45:07 -04:00
|
|
|
art_unlocks: PlayerArtUnlocksType,
|
2026-08-02 19:01:16 -04:00
|
|
|
experience: PlayerExperienceType,
|
|
|
|
|
world_time: WorldTimeServiceType,
|
2026-08-03 00:43:43 -04:00
|
|
|
world_weather: WorldWeatherServiceType,
|
|
|
|
|
jobs: PlayerJobServiceType,
|
2026-07-25 18:20:49 -04:00
|
|
|
) -> void:
|
|
|
|
|
_inventory = inventory
|
|
|
|
|
_collection_log = collection_log
|
|
|
|
|
_wallet = wallet
|
|
|
|
|
_catalog = catalog
|
2026-07-25 20:12:29 -04:00
|
|
|
_bag = bag
|
|
|
|
|
_hotbar = hotbar
|
|
|
|
|
_item_catalog = item_catalog
|
2026-07-25 21:24:01 -04:00
|
|
|
_fishing_upgrades = fishing_upgrades
|
2026-07-25 22:15:39 -04:00
|
|
|
_cooler_capacity = cooler_capacity
|
2026-08-02 00:45:07 -04:00
|
|
|
_art_unlocks = art_unlocks
|
2026-08-02 19:01:16 -04:00
|
|
|
_experience = experience
|
|
|
|
|
_world_time = world_time
|
2026-08-03 00:43:43 -04:00
|
|
|
_world_weather = world_weather
|
|
|
|
|
_jobs = jobs
|
2026-07-25 18:20:49 -04:00
|
|
|
_is_configured = (
|
|
|
|
|
_inventory != null
|
|
|
|
|
and _collection_log != null
|
|
|
|
|
and _wallet != null
|
|
|
|
|
and _catalog != null
|
2026-07-25 20:12:29 -04:00
|
|
|
and _bag != null
|
|
|
|
|
and _hotbar != null
|
|
|
|
|
and _item_catalog != null
|
2026-07-25 21:24:01 -04:00
|
|
|
and _fishing_upgrades != null
|
2026-07-25 22:15:39 -04:00
|
|
|
and _cooler_capacity != null
|
2026-08-02 00:45:07 -04:00
|
|
|
and _art_unlocks != null
|
2026-08-02 19:01:16 -04:00
|
|
|
and _experience != null
|
|
|
|
|
and _world_time != null
|
2026-08-03 00:43:43 -04:00
|
|
|
and _world_weather != null
|
|
|
|
|
and _jobs != null
|
2026-07-25 18:20:49 -04:00
|
|
|
)
|
|
|
|
|
if not _is_configured:
|
|
|
|
|
push_error("PlayerSaveManager setup is missing required references.")
|
|
|
|
|
return
|
|
|
|
|
if not _inventory.catches_changed.is_connected(_mark_dirty):
|
|
|
|
|
_inventory.catches_changed.connect(_mark_dirty)
|
2026-08-02 17:44:11 -04:00
|
|
|
if not _collection_log.collection_changed.is_connected(
|
|
|
|
|
_on_collection_changed
|
2026-07-25 18:20:49 -04:00
|
|
|
):
|
2026-08-02 17:44:11 -04:00
|
|
|
_collection_log.collection_changed.connect(_on_collection_changed)
|
2026-07-25 18:20:49 -04:00
|
|
|
if not _wallet.balance_changed.is_connected(_on_balance_changed):
|
|
|
|
|
_wallet.balance_changed.connect(_on_balance_changed)
|
2026-07-25 20:12:29 -04:00
|
|
|
if not _bag.contents_changed.is_connected(_mark_dirty):
|
|
|
|
|
_bag.contents_changed.connect(_mark_dirty)
|
|
|
|
|
if not _hotbar.slots_changed.is_connected(_mark_dirty):
|
|
|
|
|
_hotbar.slots_changed.connect(_mark_dirty)
|
|
|
|
|
if not _hotbar.selected_slot_changed.is_connected(
|
|
|
|
|
_on_selected_hotbar_slot_changed
|
|
|
|
|
):
|
|
|
|
|
_hotbar.selected_slot_changed.connect(
|
|
|
|
|
_on_selected_hotbar_slot_changed
|
|
|
|
|
)
|
2026-07-25 21:24:01 -04:00
|
|
|
if not _fishing_upgrades.upgrades_changed.is_connected(
|
|
|
|
|
_on_upgrades_changed
|
|
|
|
|
):
|
|
|
|
|
_fishing_upgrades.upgrades_changed.connect(_on_upgrades_changed)
|
2026-07-25 22:15:39 -04:00
|
|
|
if not _cooler_capacity.capacity_changed.is_connected(
|
|
|
|
|
_on_cooler_capacity_changed
|
|
|
|
|
):
|
|
|
|
|
_cooler_capacity.capacity_changed.connect(
|
|
|
|
|
_on_cooler_capacity_changed
|
|
|
|
|
)
|
2026-08-02 00:45:07 -04:00
|
|
|
if not _art_unlocks.unlocks_changed.is_connected(_on_art_unlocks_changed):
|
|
|
|
|
_art_unlocks.unlocks_changed.connect(_on_art_unlocks_changed)
|
2026-08-02 19:01:16 -04:00
|
|
|
if not _experience.experience_changed.is_connected(
|
|
|
|
|
_on_experience_changed
|
|
|
|
|
):
|
|
|
|
|
_experience.experience_changed.connect(_on_experience_changed)
|
2026-08-03 00:43:43 -04:00
|
|
|
if not _jobs.changed.is_connected(_mark_dirty):
|
|
|
|
|
_jobs.changed.connect(_mark_dirty)
|
2026-07-25 18:20:49 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
func load_player_data() -> bool:
|
|
|
|
|
if not _is_configured:
|
|
|
|
|
return false
|
|
|
|
|
_automatic_saving_blocked = false
|
|
|
|
|
_recover_interrupted_write()
|
2026-07-29 23:51:25 -04:00
|
|
|
if _save_path.is_empty() or not FileAccess.file_exists(_save_path):
|
2026-07-25 18:20:49 -04:00
|
|
|
_is_dirty = false
|
|
|
|
|
return true
|
|
|
|
|
|
2026-07-29 23:51:25 -04:00
|
|
|
var save_file := FileAccess.open(_save_path, FileAccess.READ)
|
2026-07-25 18:20:49 -04:00
|
|
|
if save_file == null:
|
|
|
|
|
_handle_corrupt_save("Unable to open player save.")
|
|
|
|
|
return false
|
|
|
|
|
var json_text: String = save_file.get_as_text()
|
|
|
|
|
save_file.close()
|
2026-07-29 23:51:25 -04:00
|
|
|
_expected_hash = PortableFileGuard.hash_file(_save_path)
|
2026-07-25 18:20:49 -04:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
var version: int = _read_integer(save_data.get("save_version"), -1)
|
|
|
|
|
if version > SAVE_VERSION:
|
|
|
|
|
_automatic_saving_blocked = true
|
|
|
|
|
push_warning(
|
|
|
|
|
(
|
|
|
|
|
"Player save version %d is newer than supported "
|
|
|
|
|
+ "version %d; the file was left untouched."
|
|
|
|
|
)
|
|
|
|
|
% [version, SAVE_VERSION]
|
|
|
|
|
)
|
|
|
|
|
_restore_defaults()
|
|
|
|
|
return false
|
|
|
|
|
if version != SAVE_VERSION:
|
|
|
|
|
save_data = _migrate_save(save_data, version)
|
|
|
|
|
if save_data.is_empty():
|
|
|
|
|
_handle_corrupt_save(
|
|
|
|
|
"Player save version %d is unsupported." % version
|
|
|
|
|
)
|
|
|
|
|
return false
|
|
|
|
|
|
|
|
|
|
var snapshot: LoadSnapshot = _build_load_snapshot(save_data)
|
|
|
|
|
if snapshot == null:
|
|
|
|
|
_handle_corrupt_save("Player save failed structural validation.")
|
|
|
|
|
return false
|
|
|
|
|
|
|
|
|
|
_is_restoring = true
|
|
|
|
|
var inventory_restored: bool = _inventory.replace_all_catches(
|
|
|
|
|
snapshot.catches,
|
|
|
|
|
snapshot.next_catch_sequence
|
|
|
|
|
)
|
|
|
|
|
var collection_restored: bool = (
|
2026-08-02 17:44:11 -04:00
|
|
|
_collection_log.replace_discovery_state(
|
|
|
|
|
snapshot.discovered_ids,
|
|
|
|
|
snapshot.discovered_quality_masks,
|
|
|
|
|
)
|
2026-07-25 18:20:49 -04:00
|
|
|
)
|
|
|
|
|
var wallet_restored: bool = _wallet.restore_balance(
|
|
|
|
|
snapshot.wallet_balance
|
|
|
|
|
)
|
2026-07-25 20:12:29 -04:00
|
|
|
var bag_restored: bool = _bag.replace_all_items(snapshot.bag_items)
|
|
|
|
|
var hotbar_restored: bool = _hotbar.replace_state(
|
|
|
|
|
snapshot.hotbar_slots,
|
2026-08-01 17:12:29 -04:00
|
|
|
snapshot.selected_hotbar_slot,
|
|
|
|
|
snapshot.fish_hotbar_slots,
|
2026-07-25 20:12:29 -04:00
|
|
|
)
|
2026-07-25 21:24:01 -04:00
|
|
|
var upgrades_restored: bool = _fishing_upgrades.restore_levels(
|
|
|
|
|
snapshot.reel_speed_level,
|
|
|
|
|
snapshot.barrier_power_level
|
|
|
|
|
)
|
2026-07-25 22:15:39 -04:00
|
|
|
var cooler_restored: bool = _cooler_capacity.restore_level(
|
|
|
|
|
snapshot.cooler_capacity_level
|
|
|
|
|
)
|
2026-08-02 00:45:07 -04:00
|
|
|
var art_restored: bool = _art_unlocks.restore_mask(
|
|
|
|
|
snapshot.art_unlock_mask
|
|
|
|
|
)
|
2026-08-02 19:01:16 -04:00
|
|
|
var experience_restored: bool = _experience.restore_total_experience(
|
|
|
|
|
snapshot.total_experience
|
|
|
|
|
)
|
|
|
|
|
var world_time_restored: bool = (
|
|
|
|
|
_world_time.restore_persistent_time_hours(snapshot.world_time_hours)
|
|
|
|
|
)
|
2026-08-03 00:43:43 -04:00
|
|
|
var world_weather_restored: bool = true
|
|
|
|
|
if snapshot.has_world_weather_state:
|
|
|
|
|
world_weather_restored = _world_weather.restore_persistent_state(
|
|
|
|
|
snapshot.world_weather,
|
|
|
|
|
snapshot.world_weather_seconds_remaining,
|
|
|
|
|
)
|
|
|
|
|
var jobs_restored: bool = _jobs.restore_from_save_data(snapshot.jobs_data)
|
2026-07-25 18:20:49 -04:00
|
|
|
_is_restoring = false
|
|
|
|
|
if (
|
|
|
|
|
not inventory_restored
|
|
|
|
|
or not collection_restored
|
|
|
|
|
or not wallet_restored
|
2026-07-25 20:12:29 -04:00
|
|
|
or not bag_restored
|
|
|
|
|
or not hotbar_restored
|
2026-07-25 21:24:01 -04:00
|
|
|
or not upgrades_restored
|
2026-07-25 22:15:39 -04:00
|
|
|
or not cooler_restored
|
2026-08-02 00:45:07 -04:00
|
|
|
or not art_restored
|
2026-08-02 19:01:16 -04:00
|
|
|
or not experience_restored
|
|
|
|
|
or not world_time_restored
|
2026-08-03 00:43:43 -04:00
|
|
|
or not world_weather_restored
|
|
|
|
|
or not jobs_restored
|
2026-07-25 18:20:49 -04:00
|
|
|
):
|
|
|
|
|
push_error("Validated player save could not be restored.")
|
|
|
|
|
return false
|
|
|
|
|
|
|
|
|
|
_is_dirty = false
|
|
|
|
|
print(
|
|
|
|
|
"Loaded player save version %d with %d catches."
|
|
|
|
|
% [SAVE_VERSION, snapshot.catches.size()]
|
|
|
|
|
)
|
|
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func inspect_save() -> SaveInspectionType:
|
|
|
|
|
var result := SaveInspectionType.new()
|
|
|
|
|
_recover_interrupted_write()
|
2026-07-29 23:51:25 -04:00
|
|
|
result.has_primary_file = FileAccess.file_exists(_save_path)
|
2026-07-25 18:20:49 -04:00
|
|
|
if not result.has_primary_file:
|
|
|
|
|
result.status = SaveInspectionType.Status.MISSING
|
2026-07-26 23:20:51 -04:00
|
|
|
result.message = "no save found."
|
2026-07-25 18:20:49 -04:00
|
|
|
return result
|
2026-07-29 23:51:25 -04:00
|
|
|
var save_file := FileAccess.open(_save_path, FileAccess.READ)
|
2026-07-25 18:20:49 -04:00
|
|
|
if save_file == null:
|
|
|
|
|
result.status = SaveInspectionType.Status.IO_ERROR
|
2026-07-26 23:20:51 -04:00
|
|
|
result.message = "the save could not be read."
|
2026-07-25 18:20:49 -04:00
|
|
|
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
|
2026-07-26 23:20:51 -04:00
|
|
|
result.message = "the save is corrupt and was preserved."
|
2026-07-25 18:20:49 -04:00
|
|
|
return result
|
|
|
|
|
var save_data: Dictionary = json.data
|
|
|
|
|
result.detected_version = _read_integer(save_data.get("save_version"), -1)
|
|
|
|
|
if result.detected_version > SAVE_VERSION:
|
|
|
|
|
result.status = SaveInspectionType.Status.UNSUPPORTED_VERSION
|
2026-07-26 23:20:51 -04:00
|
|
|
result.message = "this save belongs to a newer game version."
|
2026-07-25 18:20:49 -04:00
|
|
|
return result
|
2026-07-25 20:12:29 -04:00
|
|
|
if result.detected_version < 1:
|
2026-07-25 18:20:49 -04:00
|
|
|
result.status = SaveInspectionType.Status.MALFORMED
|
2026-07-26 23:20:51 -04:00
|
|
|
result.message = "the save version is unsupported."
|
2026-07-25 18:20:49 -04:00
|
|
|
return result
|
2026-07-25 20:12:29 -04:00
|
|
|
if result.detected_version != SAVE_VERSION:
|
|
|
|
|
save_data = _migrate_save(save_data, result.detected_version)
|
|
|
|
|
if save_data.is_empty():
|
|
|
|
|
result.status = SaveInspectionType.Status.MALFORMED
|
2026-07-26 23:20:51 -04:00
|
|
|
result.message = "the save version is unsupported."
|
2026-07-25 20:12:29 -04:00
|
|
|
return result
|
2026-07-25 18:20:49 -04:00
|
|
|
var snapshot: LoadSnapshot = _build_load_snapshot(save_data)
|
|
|
|
|
if snapshot == null:
|
|
|
|
|
result.status = SaveInspectionType.Status.MALFORMED
|
2026-07-26 23:20:51 -04:00
|
|
|
result.message = "the save is structurally invalid and was preserved."
|
2026-07-25 18:20:49 -04:00
|
|
|
return result
|
|
|
|
|
result.status = SaveInspectionType.Status.VALID_SUPPORTED
|
|
|
|
|
result.catch_count = snapshot.catches.size()
|
|
|
|
|
result.wallet_balance = snapshot.wallet_balance
|
|
|
|
|
result.discovered_species_count = snapshot.discovered_ids.size()
|
2026-07-26 23:20:51 -04:00
|
|
|
result.message = "save ready."
|
2026-07-25 18:20:49 -04:00
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func initialize_new_game() -> bool:
|
|
|
|
|
if not _is_configured:
|
|
|
|
|
return false
|
|
|
|
|
_automatic_saving_blocked = false
|
|
|
|
|
_restore_defaults()
|
|
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func delete_progression_save() -> bool:
|
2026-07-29 23:51:25 -04:00
|
|
|
if FileAccess.file_exists(_save_path) and not _remove_if_present(_save_path):
|
2026-07-25 18:20:49 -04:00
|
|
|
return false
|
2026-07-29 23:51:25 -04:00
|
|
|
for path: String in [_temp_path(), _backup_path()]:
|
2026-07-25 18:20:49 -04:00
|
|
|
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:
|
|
|
|
|
_autosave_timer.stop()
|
|
|
|
|
_is_dirty = false
|
|
|
|
|
_automatic_saving_blocked = false
|
2026-07-29 23:51:25 -04:00
|
|
|
_expected_hash = ""
|
2026-07-25 18:20:49 -04:00
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func set_autosave_enabled(enabled: bool) -> void:
|
|
|
|
|
_autosave_enabled = enabled
|
|
|
|
|
if not enabled and _autosave_timer != null:
|
|
|
|
|
_autosave_timer.stop()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func save_if_dirty() -> bool:
|
|
|
|
|
return not _is_dirty or save_now()
|
|
|
|
|
|
|
|
|
|
|
2026-08-02 19:01:16 -04:00
|
|
|
func save_world_time_checkpoint() -> bool:
|
|
|
|
|
if (
|
|
|
|
|
not _is_configured
|
|
|
|
|
or _automatic_saving_blocked
|
|
|
|
|
or not _autosave_enabled
|
|
|
|
|
):
|
|
|
|
|
return false
|
|
|
|
|
_is_dirty = true
|
|
|
|
|
return save_now()
|
|
|
|
|
|
|
|
|
|
|
2026-07-25 18:20:49 -04:00
|
|
|
func is_dirty() -> bool:
|
|
|
|
|
return _is_dirty
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func cancel_pending_autosave() -> void:
|
|
|
|
|
if _autosave_timer != null:
|
|
|
|
|
_autosave_timer.stop()
|
|
|
|
|
_is_dirty = false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func save_now() -> bool:
|
|
|
|
|
if (
|
|
|
|
|
not _is_configured
|
|
|
|
|
or _automatic_saving_blocked
|
|
|
|
|
or not _autosave_enabled
|
|
|
|
|
):
|
|
|
|
|
return false
|
|
|
|
|
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
|
|
|
|
|
|
2026-07-29 23:51:25 -04:00
|
|
|
var result := PortableFileGuard.write_guarded(
|
|
|
|
|
_save_path,
|
|
|
|
|
json_text.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 not bool(result.get("ok", false)):
|
|
|
|
|
push_warning("Unable to safely write the player save.")
|
2026-07-25 18:20:49 -04:00
|
|
|
return false
|
2026-07-29 23:51:25 -04:00
|
|
|
_expected_hash = str(result["hash"])
|
2026-07-25 18:20:49 -04:00
|
|
|
_is_dirty = false
|
|
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func delete_save_for_debug() -> bool:
|
|
|
|
|
return delete_progression_save()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _exit_tree() -> void:
|
|
|
|
|
if _is_dirty and _autosave_enabled and not _automatic_saving_blocked:
|
|
|
|
|
save_now()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _build_save_dictionary() -> Dictionary:
|
|
|
|
|
var serialized_catches: Array[Dictionary] = []
|
|
|
|
|
for fish_catch: FishCatchType in _inventory.get_all_catches():
|
|
|
|
|
if fish_catch == null or not fish_catch.is_valid():
|
|
|
|
|
push_warning(
|
|
|
|
|
"Player save aborted because inventory contains an "
|
|
|
|
|
+ "invalid runtime catch."
|
|
|
|
|
)
|
|
|
|
|
return {}
|
|
|
|
|
serialized_catches.append(fish_catch.to_save_dict())
|
|
|
|
|
|
|
|
|
|
var discovered_strings: Array[String] = []
|
|
|
|
|
for fish_id: StringName in _collection_log.get_discovered_ids():
|
|
|
|
|
if fish_id.is_empty():
|
|
|
|
|
return {}
|
|
|
|
|
discovered_strings.append(String(fish_id))
|
2026-08-02 17:44:11 -04:00
|
|
|
var serialized_quality_masks: Dictionary = {}
|
|
|
|
|
var quality_masks: Dictionary[StringName, int] = (
|
|
|
|
|
_collection_log.get_discovered_quality_masks()
|
|
|
|
|
)
|
|
|
|
|
for fish_id: StringName in quality_masks:
|
|
|
|
|
var quality_mask: int = quality_masks[fish_id]
|
|
|
|
|
if (
|
|
|
|
|
fish_id.is_empty()
|
|
|
|
|
or quality_mask <= 0
|
|
|
|
|
or (quality_mask & ~FishQualityType.ALL_TIERS_MASK) != 0
|
|
|
|
|
):
|
|
|
|
|
return {}
|
|
|
|
|
serialized_quality_masks[String(fish_id)] = quality_mask
|
2026-07-25 20:12:29 -04:00
|
|
|
var serialized_items: Array[Dictionary] = []
|
|
|
|
|
for owned: OwnedItemType in _bag.get_all_items():
|
|
|
|
|
if owned == null or not owned.is_valid():
|
|
|
|
|
return {}
|
|
|
|
|
serialized_items.append(owned.to_save_dict())
|
|
|
|
|
var serialized_slots: Array[String] = []
|
|
|
|
|
for item_id: StringName in _hotbar.get_slots():
|
|
|
|
|
serialized_slots.append(String(item_id))
|
2026-08-01 17:12:29 -04:00
|
|
|
var serialized_fish_slots: Array[String] = []
|
|
|
|
|
for catch_id: StringName in _hotbar.get_fish_slots():
|
|
|
|
|
serialized_fish_slots.append(String(catch_id))
|
2026-07-25 18:20:49 -04:00
|
|
|
if (
|
|
|
|
|
_wallet.get_balance() < 0
|
|
|
|
|
or _wallet.get_balance() > MAX_SAFE_BALANCE
|
|
|
|
|
or _inventory.get_next_catch_sequence() < 1
|
|
|
|
|
or (
|
|
|
|
|
_inventory.get_next_catch_sequence()
|
|
|
|
|
> FishCatchType.MAX_SAFE_SEQUENCE
|
|
|
|
|
)
|
|
|
|
|
):
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"save_version": SAVE_VERSION,
|
|
|
|
|
"wallet": {
|
|
|
|
|
"balance": _wallet.get_balance(),
|
|
|
|
|
},
|
|
|
|
|
"collection": {
|
|
|
|
|
"discovered_fish_ids": discovered_strings,
|
2026-08-02 17:44:11 -04:00
|
|
|
"discovered_quality_masks": serialized_quality_masks,
|
2026-07-25 18:20:49 -04:00
|
|
|
},
|
|
|
|
|
"inventory": {
|
|
|
|
|
"next_catch_sequence": _inventory.get_next_catch_sequence(),
|
|
|
|
|
"catches": serialized_catches,
|
|
|
|
|
},
|
2026-07-25 20:12:29 -04:00
|
|
|
"bag": {
|
|
|
|
|
"items": serialized_items,
|
|
|
|
|
},
|
|
|
|
|
"hotbar": {
|
|
|
|
|
"selected_slot": _hotbar.get_selected_slot(),
|
|
|
|
|
"slots": serialized_slots,
|
2026-08-01 17:12:29 -04:00
|
|
|
"fish_slots": serialized_fish_slots,
|
2026-07-25 20:12:29 -04:00
|
|
|
},
|
2026-07-25 21:24:01 -04:00
|
|
|
"upgrades": _fishing_upgrades.to_save_data(),
|
2026-07-25 22:15:39 -04:00
|
|
|
"cooler": _cooler_capacity.to_save_data(),
|
2026-08-02 00:45:07 -04:00
|
|
|
"art": _art_unlocks.to_save_data(),
|
2026-08-02 19:01:16 -04:00
|
|
|
"experience": _experience.to_save_data(),
|
|
|
|
|
"world": {
|
|
|
|
|
"time_hours": _world_time.get_persistent_time_hours(),
|
2026-08-03 00:43:43 -04:00
|
|
|
"weather": int(_world_weather.get_persistent_weather()),
|
|
|
|
|
"weather_seconds_remaining": (
|
|
|
|
|
_world_weather.get_persistent_seconds_remaining()
|
|
|
|
|
),
|
2026-08-02 19:01:16 -04:00
|
|
|
},
|
2026-08-03 00:43:43 -04:00
|
|
|
"jobs": _jobs.to_save_data(),
|
2026-07-25 18:20:49 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
|
|
|
|
|
if (
|
|
|
|
|
typeof(save_data.get("wallet")) != TYPE_DICTIONARY
|
|
|
|
|
or typeof(save_data.get("collection")) != TYPE_DICTIONARY
|
|
|
|
|
or typeof(save_data.get("inventory")) != TYPE_DICTIONARY
|
2026-07-25 20:12:29 -04:00
|
|
|
or typeof(save_data.get("bag")) != TYPE_DICTIONARY
|
|
|
|
|
or typeof(save_data.get("hotbar")) != TYPE_DICTIONARY
|
2026-08-02 19:01:16 -04:00
|
|
|
or typeof(save_data.get("experience")) != TYPE_DICTIONARY
|
2026-08-03 00:43:43 -04:00
|
|
|
or typeof(save_data.get("jobs")) != TYPE_DICTIONARY
|
2026-07-25 18:20:49 -04:00
|
|
|
):
|
|
|
|
|
return null
|
|
|
|
|
var wallet_data: Dictionary = save_data["wallet"]
|
|
|
|
|
var collection_data: Dictionary = save_data["collection"]
|
|
|
|
|
var inventory_data: Dictionary = save_data["inventory"]
|
2026-07-25 20:12:29 -04:00
|
|
|
var bag_data: Dictionary = save_data["bag"]
|
|
|
|
|
var hotbar_data: Dictionary = save_data["hotbar"]
|
2026-08-02 19:01:16 -04:00
|
|
|
var experience_data: Dictionary = save_data["experience"]
|
2026-08-03 00:43:43 -04:00
|
|
|
var jobs_data: Dictionary = save_data["jobs"]
|
2026-08-02 19:01:16 -04:00
|
|
|
var world_data: Dictionary = {}
|
|
|
|
|
if typeof(save_data.get("world")) == TYPE_DICTIONARY:
|
|
|
|
|
world_data = save_data["world"]
|
2026-07-25 21:24:01 -04:00
|
|
|
var upgrades_data: Dictionary = {}
|
2026-07-25 22:15:39 -04:00
|
|
|
var cooler_data: Dictionary = {}
|
2026-08-02 00:45:07 -04:00
|
|
|
var art_data: Dictionary = {}
|
2026-07-25 21:24:01 -04:00
|
|
|
if typeof(save_data.get("upgrades")) == TYPE_DICTIONARY:
|
|
|
|
|
upgrades_data = save_data["upgrades"]
|
|
|
|
|
else:
|
|
|
|
|
push_warning(
|
|
|
|
|
"Saved fishing upgrades were missing or invalid; using defaults."
|
|
|
|
|
)
|
2026-07-25 22:15:39 -04:00
|
|
|
if typeof(save_data.get("cooler")) == TYPE_DICTIONARY:
|
|
|
|
|
cooler_data = save_data["cooler"]
|
|
|
|
|
else:
|
|
|
|
|
push_warning("Saved Cooler data was missing or invalid; using defaults.")
|
2026-08-02 00:45:07 -04:00
|
|
|
if typeof(save_data.get("art")) == TYPE_DICTIONARY:
|
|
|
|
|
art_data = save_data["art"]
|
2026-07-25 18:20:49 -04:00
|
|
|
if (
|
|
|
|
|
not wallet_data.has("balance")
|
|
|
|
|
or typeof(collection_data.get("discovered_fish_ids")) != TYPE_ARRAY
|
2026-08-02 17:44:11 -04:00
|
|
|
or typeof(collection_data.get("discovered_quality_masks"))
|
|
|
|
|
!= TYPE_DICTIONARY
|
2026-07-25 18:20:49 -04:00
|
|
|
or typeof(inventory_data.get("catches")) != TYPE_ARRAY
|
|
|
|
|
or not inventory_data.has("next_catch_sequence")
|
2026-07-25 20:12:29 -04:00
|
|
|
or typeof(bag_data.get("items")) != TYPE_ARRAY
|
|
|
|
|
or typeof(hotbar_data.get("slots")) != TYPE_ARRAY
|
|
|
|
|
or not hotbar_data.has("selected_slot")
|
2026-08-02 19:01:16 -04:00
|
|
|
or not experience_data.has("total_experience")
|
2026-07-25 18:20:49 -04:00
|
|
|
):
|
|
|
|
|
return null
|
|
|
|
|
|
|
|
|
|
var balance: int = _read_integer(
|
|
|
|
|
wallet_data["balance"],
|
|
|
|
|
-1,
|
|
|
|
|
MAX_SAFE_BALANCE
|
|
|
|
|
)
|
|
|
|
|
var requested_next_sequence: int = _read_integer(
|
|
|
|
|
inventory_data["next_catch_sequence"],
|
|
|
|
|
-1,
|
|
|
|
|
FishCatchType.MAX_SAFE_SEQUENCE
|
|
|
|
|
)
|
|
|
|
|
if balance < 0 or requested_next_sequence < 1:
|
|
|
|
|
return null
|
|
|
|
|
|
|
|
|
|
var snapshot := LoadSnapshot.new()
|
|
|
|
|
snapshot.wallet_balance = balance
|
2026-08-02 19:01:16 -04:00
|
|
|
snapshot.total_experience = _read_integer(
|
|
|
|
|
experience_data["total_experience"],
|
|
|
|
|
-1,
|
|
|
|
|
PlayerExperienceType.MAX_TOTAL_EXPERIENCE,
|
|
|
|
|
)
|
|
|
|
|
if snapshot.total_experience < 0:
|
|
|
|
|
return null
|
2026-08-03 00:43:43 -04:00
|
|
|
if not PlayerJobServiceType.validate_save_data(jobs_data):
|
|
|
|
|
return null
|
|
|
|
|
snapshot.jobs_data = jobs_data.duplicate(true)
|
2026-08-02 19:01:16 -04:00
|
|
|
if world_data.has("time_hours"):
|
|
|
|
|
snapshot.world_time_hours = _read_world_time_hours(
|
|
|
|
|
world_data["time_hours"]
|
|
|
|
|
)
|
|
|
|
|
if snapshot.world_time_hours < 0.0:
|
|
|
|
|
return null
|
2026-08-03 00:43:43 -04:00
|
|
|
var has_weather: bool = world_data.has("weather")
|
|
|
|
|
var has_weather_seconds: bool = world_data.has(
|
|
|
|
|
"weather_seconds_remaining"
|
|
|
|
|
)
|
|
|
|
|
if has_weather != has_weather_seconds:
|
|
|
|
|
return null
|
|
|
|
|
if has_weather:
|
|
|
|
|
var weather_value: int = _read_integer(
|
|
|
|
|
world_data["weather"],
|
|
|
|
|
-1,
|
|
|
|
|
WorldWeatherServiceType.Weather.FOGGY,
|
|
|
|
|
)
|
|
|
|
|
var weather_seconds: float = _read_world_weather_seconds(
|
|
|
|
|
world_data["weather_seconds_remaining"]
|
|
|
|
|
)
|
|
|
|
|
if (
|
|
|
|
|
not WorldWeatherServiceType.is_valid_weather(weather_value)
|
|
|
|
|
or weather_seconds < 0.0
|
|
|
|
|
):
|
|
|
|
|
return null
|
|
|
|
|
snapshot.has_world_weather_state = true
|
|
|
|
|
snapshot.world_weather = (
|
|
|
|
|
weather_value as WorldWeatherServiceType.Weather
|
|
|
|
|
)
|
|
|
|
|
snapshot.world_weather_seconds_remaining = weather_seconds
|
2026-07-25 18:20:49 -04:00
|
|
|
var discovered_values: Array = collection_data["discovered_fish_ids"]
|
|
|
|
|
var seen_discoveries: Dictionary[StringName, bool] = {}
|
|
|
|
|
for value: Variant in discovered_values:
|
|
|
|
|
if typeof(value) != TYPE_STRING and typeof(value) != TYPE_STRING_NAME:
|
|
|
|
|
continue
|
|
|
|
|
var fish_id: StringName = StringName(str(value))
|
|
|
|
|
if fish_id.is_empty() or seen_discoveries.has(fish_id):
|
|
|
|
|
continue
|
|
|
|
|
seen_discoveries[fish_id] = true
|
|
|
|
|
snapshot.discovered_ids.append(fish_id)
|
2026-08-02 17:44:11 -04:00
|
|
|
var quality_mask_values: Dictionary = (
|
|
|
|
|
collection_data["discovered_quality_masks"]
|
|
|
|
|
)
|
|
|
|
|
for key: Variant in quality_mask_values:
|
|
|
|
|
if typeof(key) not in [TYPE_STRING, TYPE_STRING_NAME]:
|
|
|
|
|
return null
|
|
|
|
|
var fish_id: StringName = StringName(str(key))
|
|
|
|
|
var mask: int = _read_integer(
|
|
|
|
|
quality_mask_values[key],
|
|
|
|
|
-1,
|
|
|
|
|
FishQualityType.ALL_TIERS_MASK,
|
|
|
|
|
)
|
|
|
|
|
if (
|
|
|
|
|
fish_id.is_empty()
|
|
|
|
|
or not seen_discoveries.has(fish_id)
|
|
|
|
|
or mask <= 0
|
|
|
|
|
or (mask & ~FishQualityType.ALL_TIERS_MASK) != 0
|
|
|
|
|
):
|
|
|
|
|
return null
|
|
|
|
|
snapshot.discovered_quality_masks[fish_id] = mask
|
2026-07-25 18:20:49 -04:00
|
|
|
|
|
|
|
|
var seen_ids: Dictionary[StringName, bool] = {}
|
|
|
|
|
var seen_sequences: Dictionary[int, bool] = {}
|
|
|
|
|
var maximum_sequence: int = 0
|
|
|
|
|
var catch_values: Array = inventory_data["catches"]
|
|
|
|
|
for catch_index: int in range(catch_values.size()):
|
|
|
|
|
var catch_value: Variant = catch_values[catch_index]
|
|
|
|
|
if typeof(catch_value) != TYPE_DICTIONARY:
|
|
|
|
|
push_warning(
|
|
|
|
|
"Skipped invalid saved catch at index %d." % catch_index
|
|
|
|
|
)
|
|
|
|
|
continue
|
|
|
|
|
var catch_data: Dictionary = catch_value
|
|
|
|
|
var fish_id: StringName = StringName(str(catch_data.get("fish_id", "")))
|
|
|
|
|
var fish_data = _catalog.get_fish_by_id(fish_id)
|
|
|
|
|
if fish_data == null:
|
|
|
|
|
push_warning(
|
|
|
|
|
"Skipped saved catch with missing fish ID '%s'."
|
|
|
|
|
% String(fish_id)
|
|
|
|
|
)
|
|
|
|
|
continue
|
|
|
|
|
var fish_catch: FishCatchType = FishCatchType.from_save_dict(
|
|
|
|
|
catch_data,
|
|
|
|
|
fish_data
|
|
|
|
|
)
|
|
|
|
|
if fish_catch == null:
|
|
|
|
|
push_warning(
|
|
|
|
|
"Skipped structurally invalid saved catch at index %d."
|
|
|
|
|
% catch_index
|
|
|
|
|
)
|
|
|
|
|
continue
|
|
|
|
|
if seen_ids.has(fish_catch.catch_id):
|
|
|
|
|
push_warning(
|
|
|
|
|
"Skipped duplicate saved catch ID '%s'."
|
|
|
|
|
% String(fish_catch.catch_id)
|
|
|
|
|
)
|
|
|
|
|
continue
|
|
|
|
|
if seen_sequences.has(fish_catch.catch_sequence):
|
|
|
|
|
push_warning(
|
|
|
|
|
"Skipped duplicate saved catch sequence %d."
|
|
|
|
|
% fish_catch.catch_sequence
|
|
|
|
|
)
|
|
|
|
|
continue
|
|
|
|
|
seen_ids[fish_catch.catch_id] = true
|
|
|
|
|
seen_sequences[fish_catch.catch_sequence] = true
|
|
|
|
|
maximum_sequence = maxi(
|
|
|
|
|
maximum_sequence,
|
|
|
|
|
fish_catch.catch_sequence
|
|
|
|
|
)
|
|
|
|
|
snapshot.catches.append(fish_catch)
|
|
|
|
|
|
|
|
|
|
snapshot.next_catch_sequence = maxi(
|
|
|
|
|
requested_next_sequence,
|
|
|
|
|
maximum_sequence + 1
|
|
|
|
|
)
|
2026-07-25 20:12:29 -04:00
|
|
|
|
|
|
|
|
var seen_items: Dictionary[StringName, bool] = {}
|
|
|
|
|
var item_values: Array = bag_data["items"]
|
|
|
|
|
for item_index: int in range(item_values.size()):
|
|
|
|
|
var value: Variant = item_values[item_index]
|
|
|
|
|
if typeof(value) != TYPE_DICTIONARY:
|
|
|
|
|
push_warning("Skipped invalid saved Bag item.")
|
|
|
|
|
continue
|
|
|
|
|
var item_record: Dictionary = value
|
|
|
|
|
var item_id: StringName = StringName(
|
|
|
|
|
str(item_record.get("item_id", ""))
|
|
|
|
|
)
|
|
|
|
|
var item_data = _item_catalog.get_item_by_id(item_id)
|
|
|
|
|
var quantity: int = _read_integer(
|
|
|
|
|
item_record.get("quantity"),
|
|
|
|
|
-1,
|
|
|
|
|
999
|
|
|
|
|
)
|
|
|
|
|
if (
|
|
|
|
|
item_id.is_empty()
|
|
|
|
|
or item_data == null
|
|
|
|
|
or not item_data.is_valid()
|
|
|
|
|
or quantity <= 0
|
|
|
|
|
or seen_items.has(item_id)
|
|
|
|
|
):
|
|
|
|
|
push_warning(
|
|
|
|
|
"Skipped invalid or unknown saved Bag item '%s'."
|
|
|
|
|
% String(item_id)
|
|
|
|
|
)
|
|
|
|
|
continue
|
|
|
|
|
var maximum: int = (
|
|
|
|
|
item_data.max_stack if item_data.stackable else 1
|
|
|
|
|
)
|
|
|
|
|
if quantity > maximum:
|
|
|
|
|
push_warning(
|
|
|
|
|
"Skipped saved Bag item with invalid quantity '%s'."
|
|
|
|
|
% String(item_id)
|
|
|
|
|
)
|
|
|
|
|
continue
|
|
|
|
|
var owned := OwnedItemType.new()
|
|
|
|
|
owned.item_id = item_id
|
|
|
|
|
owned.quantity = quantity
|
|
|
|
|
seen_items[item_id] = true
|
|
|
|
|
snapshot.bag_items.append(owned)
|
|
|
|
|
|
|
|
|
|
var slot_values: Array = hotbar_data["slots"]
|
|
|
|
|
snapshot.hotbar_slots.resize(PlayerHotbarType.SLOT_COUNT)
|
|
|
|
|
snapshot.hotbar_slots.fill(StringName())
|
|
|
|
|
for slot_index: int in range(
|
|
|
|
|
mini(slot_values.size(), PlayerHotbarType.SLOT_COUNT)
|
|
|
|
|
):
|
|
|
|
|
var slot_value: Variant = slot_values[slot_index]
|
|
|
|
|
if typeof(slot_value) not in [TYPE_STRING, TYPE_STRING_NAME]:
|
|
|
|
|
continue
|
|
|
|
|
var slot_item_id: StringName = StringName(str(slot_value))
|
|
|
|
|
if slot_item_id.is_empty():
|
|
|
|
|
continue
|
|
|
|
|
var slot_item = _item_catalog.get_item_by_id(slot_item_id)
|
|
|
|
|
if (
|
|
|
|
|
slot_item != null
|
|
|
|
|
and slot_item.is_valid()
|
|
|
|
|
and slot_item.hotbar_allowed
|
|
|
|
|
and seen_items.has(slot_item_id)
|
|
|
|
|
):
|
|
|
|
|
snapshot.hotbar_slots[slot_index] = slot_item_id
|
2026-08-01 17:12:29 -04:00
|
|
|
var fish_slot_values: Array = []
|
|
|
|
|
if typeof(hotbar_data.get("fish_slots")) == TYPE_ARRAY:
|
|
|
|
|
fish_slot_values = hotbar_data["fish_slots"]
|
|
|
|
|
snapshot.fish_hotbar_slots.resize(PlayerHotbarType.SLOT_COUNT)
|
|
|
|
|
snapshot.fish_hotbar_slots.fill(StringName())
|
|
|
|
|
for slot_index: int in range(
|
|
|
|
|
mini(fish_slot_values.size(), PlayerHotbarType.SLOT_COUNT)
|
|
|
|
|
):
|
|
|
|
|
if not snapshot.hotbar_slots[slot_index].is_empty():
|
|
|
|
|
continue
|
|
|
|
|
var slot_value: Variant = fish_slot_values[slot_index]
|
|
|
|
|
if typeof(slot_value) not in [TYPE_STRING, TYPE_STRING_NAME]:
|
|
|
|
|
continue
|
|
|
|
|
var catch_id: StringName = StringName(str(slot_value))
|
|
|
|
|
if not catch_id.is_empty() and seen_ids.has(catch_id):
|
|
|
|
|
snapshot.fish_hotbar_slots[slot_index] = catch_id
|
2026-07-25 20:12:29 -04:00
|
|
|
var selected_slot: int = _read_integer(
|
|
|
|
|
hotbar_data["selected_slot"],
|
|
|
|
|
0,
|
|
|
|
|
PlayerHotbarType.SLOT_COUNT - 1
|
|
|
|
|
)
|
|
|
|
|
snapshot.selected_hotbar_slot = clampi(
|
|
|
|
|
selected_slot,
|
|
|
|
|
0,
|
|
|
|
|
PlayerHotbarType.SLOT_COUNT - 1
|
|
|
|
|
)
|
2026-07-25 21:24:01 -04:00
|
|
|
snapshot.reel_speed_level = _read_upgrade_level(
|
|
|
|
|
upgrades_data.get("reel_speed_level"),
|
|
|
|
|
PlayerFishingUpgradesType.MAX_REEL_SPEED_LEVEL,
|
|
|
|
|
"reel_speed_level"
|
|
|
|
|
)
|
|
|
|
|
snapshot.barrier_power_level = _read_upgrade_level(
|
|
|
|
|
upgrades_data.get("barrier_power_level"),
|
|
|
|
|
PlayerFishingUpgradesType.MAX_BARRIER_POWER_LEVEL,
|
|
|
|
|
"barrier_power_level"
|
|
|
|
|
)
|
2026-07-25 22:15:39 -04:00
|
|
|
snapshot.cooler_capacity_level = _read_upgrade_level(
|
|
|
|
|
cooler_data.get("capacity_level"),
|
|
|
|
|
PlayerCoolerCapacityType.MAX_LEVEL,
|
|
|
|
|
"cooler.capacity_level"
|
|
|
|
|
)
|
2026-08-02 00:45:07 -04:00
|
|
|
snapshot.art_unlock_mask = _read_integer(
|
|
|
|
|
art_data.get("unlock_mask"),
|
|
|
|
|
0,
|
|
|
|
|
PlayerArtUnlocksType.ALL_UNLOCK_MASK,
|
|
|
|
|
)
|
2026-07-25 18:20:49 -04:00
|
|
|
return snapshot
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _migrate_save(
|
|
|
|
|
data: Dictionary,
|
|
|
|
|
from_version: int,
|
|
|
|
|
) -> Dictionary:
|
|
|
|
|
if from_version == SAVE_VERSION:
|
|
|
|
|
return data
|
2026-07-25 21:24:01 -04:00
|
|
|
if from_version < 1 or from_version > SAVE_VERSION:
|
|
|
|
|
return {}
|
|
|
|
|
var migrated: Dictionary = data.duplicate(true)
|
|
|
|
|
var current_version: int = from_version
|
|
|
|
|
while current_version < SAVE_VERSION:
|
|
|
|
|
match current_version:
|
|
|
|
|
1:
|
|
|
|
|
migrated = _migrate_version_1_to_2(migrated)
|
|
|
|
|
2:
|
|
|
|
|
migrated = _migrate_version_2_to_3(migrated)
|
2026-07-25 22:15:39 -04:00
|
|
|
3:
|
|
|
|
|
migrated = _migrate_version_3_to_4(migrated)
|
2026-08-02 17:44:11 -04:00
|
|
|
4:
|
|
|
|
|
migrated = _migrate_version_4_to_5(migrated)
|
2026-08-02 19:01:16 -04:00
|
|
|
5:
|
|
|
|
|
migrated = _migrate_version_5_to_6(migrated)
|
2026-08-03 00:43:43 -04:00
|
|
|
6:
|
|
|
|
|
migrated = _migrate_version_6_to_7(migrated)
|
2026-07-25 21:24:01 -04:00
|
|
|
_:
|
|
|
|
|
return {}
|
|
|
|
|
if migrated.is_empty():
|
|
|
|
|
return {}
|
|
|
|
|
current_version += 1
|
|
|
|
|
return migrated
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _migrate_version_1_to_2(data: Dictionary) -> Dictionary:
|
|
|
|
|
var migrated: Dictionary = data.duplicate(true)
|
|
|
|
|
migrated["save_version"] = 2
|
|
|
|
|
migrated["bag"] = {
|
2026-07-25 20:12:29 -04:00
|
|
|
"items": [
|
|
|
|
|
{
|
|
|
|
|
"item_id": String(BASIC_ROD_ID),
|
|
|
|
|
"quantity": 1,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
}
|
2026-07-25 21:24:01 -04:00
|
|
|
var slots: Array[String] = []
|
|
|
|
|
slots.resize(PlayerHotbarType.SLOT_COUNT)
|
|
|
|
|
slots.fill("")
|
|
|
|
|
slots[0] = String(BASIC_ROD_ID)
|
|
|
|
|
migrated["hotbar"] = {
|
|
|
|
|
"selected_slot": 0,
|
|
|
|
|
"slots": slots,
|
|
|
|
|
}
|
|
|
|
|
return migrated
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _migrate_version_2_to_3(data: Dictionary) -> Dictionary:
|
|
|
|
|
var migrated: Dictionary = data.duplicate(true)
|
|
|
|
|
migrated["save_version"] = 3
|
|
|
|
|
migrated["upgrades"] = {
|
|
|
|
|
"reel_speed_level": 0,
|
|
|
|
|
"barrier_power_level": 0,
|
|
|
|
|
}
|
|
|
|
|
return migrated
|
2026-07-25 18:20:49 -04:00
|
|
|
|
|
|
|
|
|
2026-07-25 22:15:39 -04:00
|
|
|
func _migrate_version_3_to_4(data: Dictionary) -> Dictionary:
|
|
|
|
|
var migrated: Dictionary = data.duplicate(true)
|
|
|
|
|
migrated["save_version"] = 4
|
|
|
|
|
migrated["cooler"] = {"capacity_level": 0}
|
|
|
|
|
return migrated
|
|
|
|
|
|
|
|
|
|
|
2026-08-02 17:44:11 -04:00
|
|
|
func _migrate_version_4_to_5(data: Dictionary) -> Dictionary:
|
|
|
|
|
var migrated: Dictionary = data.duplicate(true)
|
|
|
|
|
if (
|
|
|
|
|
typeof(migrated.get("inventory")) != TYPE_DICTIONARY
|
|
|
|
|
or typeof(migrated.get("collection")) != TYPE_DICTIONARY
|
|
|
|
|
):
|
|
|
|
|
return {}
|
|
|
|
|
var inventory_data: Dictionary = migrated["inventory"]
|
|
|
|
|
var collection_data: Dictionary = migrated["collection"]
|
|
|
|
|
if (
|
|
|
|
|
typeof(inventory_data.get("catches")) != TYPE_ARRAY
|
|
|
|
|
or typeof(collection_data.get("discovered_fish_ids")) != TYPE_ARRAY
|
|
|
|
|
):
|
|
|
|
|
return {}
|
|
|
|
|
var catches: Array = inventory_data["catches"]
|
|
|
|
|
var discovered_values: Array = collection_data["discovered_fish_ids"]
|
|
|
|
|
var discovered_lookup: Dictionary[String, bool] = {}
|
|
|
|
|
for value: Variant in discovered_values:
|
|
|
|
|
if typeof(value) in [TYPE_STRING, TYPE_STRING_NAME]:
|
|
|
|
|
var existing_id: String = str(value)
|
|
|
|
|
if not existing_id.is_empty():
|
|
|
|
|
discovered_lookup[existing_id] = true
|
|
|
|
|
for index: int in catches.size():
|
|
|
|
|
if typeof(catches[index]) != TYPE_DICTIONARY:
|
|
|
|
|
continue
|
|
|
|
|
var catch_data: Dictionary = catches[index]
|
|
|
|
|
catch_data["quality"] = FishQualityType.Tier.BORING
|
|
|
|
|
var catch_fish_id: String = str(catch_data.get("fish_id", ""))
|
|
|
|
|
if not catch_fish_id.is_empty() and not discovered_lookup.has(
|
|
|
|
|
catch_fish_id
|
|
|
|
|
):
|
|
|
|
|
discovered_values.append(catch_fish_id)
|
|
|
|
|
discovered_lookup[catch_fish_id] = true
|
|
|
|
|
catches[index] = catch_data
|
|
|
|
|
inventory_data["catches"] = catches
|
|
|
|
|
migrated["inventory"] = inventory_data
|
|
|
|
|
var quality_masks: Dictionary = {}
|
|
|
|
|
for value: Variant in discovered_values:
|
|
|
|
|
if typeof(value) not in [TYPE_STRING, TYPE_STRING_NAME]:
|
|
|
|
|
continue
|
|
|
|
|
var fish_id: String = str(value)
|
|
|
|
|
if not fish_id.is_empty():
|
|
|
|
|
quality_masks[fish_id] = FishQualityType.bit_for(
|
|
|
|
|
FishQualityType.Tier.BORING
|
|
|
|
|
)
|
|
|
|
|
collection_data["discovered_quality_masks"] = quality_masks
|
|
|
|
|
collection_data["discovered_fish_ids"] = discovered_values
|
|
|
|
|
migrated["collection"] = collection_data
|
|
|
|
|
migrated["save_version"] = 5
|
|
|
|
|
return migrated
|
|
|
|
|
|
|
|
|
|
|
2026-08-02 19:01:16 -04:00
|
|
|
func _migrate_version_5_to_6(data: Dictionary) -> Dictionary:
|
|
|
|
|
var migrated: Dictionary = data.duplicate(true)
|
|
|
|
|
migrated["save_version"] = 6
|
|
|
|
|
migrated["experience"] = {"total_experience": 0}
|
|
|
|
|
migrated["world"] = {
|
|
|
|
|
"time_hours": WorldTimeServiceType.DEFAULT_START_HOUR,
|
|
|
|
|
}
|
|
|
|
|
return migrated
|
|
|
|
|
|
|
|
|
|
|
2026-08-03 00:43:43 -04:00
|
|
|
func _migrate_version_6_to_7(data: Dictionary) -> Dictionary:
|
|
|
|
|
var migrated: Dictionary = data.duplicate(true)
|
|
|
|
|
migrated["save_version"] = 7
|
|
|
|
|
# Historical catch and sale totals cannot be reconstructed honestly from
|
|
|
|
|
# current inventory. New cumulative counters begin at feature introduction.
|
|
|
|
|
migrated["jobs"] = PlayerJobServiceType.default_save_data()
|
|
|
|
|
return migrated
|
|
|
|
|
|
|
|
|
|
|
2026-07-25 18:20:49 -04:00
|
|
|
func _mark_dirty() -> void:
|
|
|
|
|
if (
|
|
|
|
|
_is_restoring
|
|
|
|
|
or _automatic_saving_blocked
|
|
|
|
|
or not _autosave_enabled
|
|
|
|
|
):
|
|
|
|
|
return
|
|
|
|
|
_is_dirty = true
|
|
|
|
|
if _autosave_timer != null and _autosave_timer.is_stopped():
|
|
|
|
|
_autosave_timer.start(maxf(autosave_delay, 0.05))
|
|
|
|
|
|
|
|
|
|
|
2026-08-02 17:44:11 -04:00
|
|
|
func _on_collection_changed() -> void:
|
2026-07-25 18:20:49 -04:00
|
|
|
_mark_dirty()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _on_balance_changed(_new_balance: int, _delta: int) -> void:
|
|
|
|
|
_mark_dirty()
|
|
|
|
|
|
|
|
|
|
|
2026-07-25 20:12:29 -04:00
|
|
|
func _on_selected_hotbar_slot_changed(
|
|
|
|
|
_slot_index: int,
|
|
|
|
|
_item_id: StringName,
|
|
|
|
|
) -> void:
|
|
|
|
|
_mark_dirty()
|
|
|
|
|
|
|
|
|
|
|
2026-07-25 21:24:01 -04:00
|
|
|
func _on_upgrades_changed(
|
|
|
|
|
_reel_speed_level: int,
|
|
|
|
|
_barrier_power_level: int,
|
|
|
|
|
) -> void:
|
|
|
|
|
_mark_dirty()
|
|
|
|
|
|
|
|
|
|
|
2026-07-25 22:15:39 -04:00
|
|
|
func _on_cooler_capacity_changed(
|
|
|
|
|
_level: int,
|
|
|
|
|
_capacity: int,
|
|
|
|
|
) -> void:
|
|
|
|
|
_mark_dirty()
|
|
|
|
|
|
|
|
|
|
|
2026-08-02 00:45:07 -04:00
|
|
|
func _on_art_unlocks_changed(_unlock_mask: int) -> void:
|
|
|
|
|
_mark_dirty()
|
|
|
|
|
|
|
|
|
|
|
2026-08-02 19:01:16 -04:00
|
|
|
func _on_experience_changed(_total_experience: int, _level: int) -> void:
|
|
|
|
|
_mark_dirty()
|
|
|
|
|
|
|
|
|
|
|
2026-07-25 18:20:49 -04:00
|
|
|
func _on_autosave_timeout() -> void:
|
|
|
|
|
if _is_dirty:
|
|
|
|
|
save_now()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _recover_interrupted_write() -> void:
|
2026-07-29 23:51:25 -04:00
|
|
|
if FileAccess.file_exists(_save_path):
|
|
|
|
|
_remove_if_present(_temp_path())
|
|
|
|
|
_remove_if_present(_backup_path())
|
2026-07-25 18:20:49 -04:00
|
|
|
return
|
2026-07-29 23:51:25 -04:00
|
|
|
if FileAccess.file_exists(_backup_path()):
|
|
|
|
|
_rename_file(_backup_path(), _save_path)
|
|
|
|
|
_remove_if_present(_temp_path())
|
2026-07-25 18:20:49 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
func _handle_corrupt_save(message: String) -> void:
|
|
|
|
|
push_warning(message)
|
|
|
|
|
_restore_defaults()
|
2026-07-29 23:51:25 -04:00
|
|
|
if not FileAccess.file_exists(_save_path):
|
2026-07-25 18:20:49 -04:00
|
|
|
return
|
|
|
|
|
var timestamp: int = int(Time.get_unix_time_from_system())
|
|
|
|
|
var corrupt_path: String = (
|
2026-07-29 23:51:25 -04:00
|
|
|
"%s.corrupt-%d" % [_save_path, timestamp]
|
2026-07-25 18:20:49 -04:00
|
|
|
)
|
|
|
|
|
if FileAccess.file_exists(corrupt_path):
|
|
|
|
|
corrupt_path += "-%d" % Time.get_ticks_usec()
|
2026-07-29 23:51:25 -04:00
|
|
|
if not _rename_file(_save_path, corrupt_path):
|
2026-07-25 18:20:49 -04:00
|
|
|
_automatic_saving_blocked = true
|
|
|
|
|
push_warning(
|
|
|
|
|
"Corrupt player save was left in place; automatic saving is "
|
|
|
|
|
+ "disabled for this run."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _restore_defaults() -> void:
|
|
|
|
|
if not _is_configured:
|
|
|
|
|
return
|
|
|
|
|
_is_restoring = true
|
|
|
|
|
var empty_catches: Array[FishCatchType] = []
|
|
|
|
|
var empty_discoveries: Array[StringName] = []
|
2026-08-02 17:44:11 -04:00
|
|
|
var empty_quality_masks: Dictionary[StringName, int] = {}
|
2026-07-25 20:12:29 -04:00
|
|
|
var default_items: Array[OwnedItemType] = []
|
|
|
|
|
var basic_rod := OwnedItemType.new()
|
|
|
|
|
basic_rod.item_id = BASIC_ROD_ID
|
|
|
|
|
basic_rod.quantity = 1
|
|
|
|
|
default_items.append(basic_rod)
|
|
|
|
|
var default_slots: Array[StringName] = []
|
|
|
|
|
default_slots.resize(PlayerHotbarType.SLOT_COUNT)
|
|
|
|
|
default_slots.fill(StringName())
|
|
|
|
|
default_slots[0] = BASIC_ROD_ID
|
2026-07-25 18:20:49 -04:00
|
|
|
_inventory.replace_all_catches(empty_catches, 1)
|
2026-08-02 17:44:11 -04:00
|
|
|
_collection_log.replace_discovery_state(
|
|
|
|
|
empty_discoveries,
|
|
|
|
|
empty_quality_masks,
|
|
|
|
|
)
|
2026-07-25 18:20:49 -04:00
|
|
|
_wallet.restore_balance(0)
|
2026-07-25 20:12:29 -04:00
|
|
|
_bag.replace_all_items(default_items)
|
|
|
|
|
_hotbar.replace_state(default_slots, 0)
|
2026-07-25 21:24:01 -04:00
|
|
|
_fishing_upgrades.reset_to_defaults()
|
2026-07-25 22:15:39 -04:00
|
|
|
_cooler_capacity.reset_to_defaults()
|
2026-08-02 00:45:07 -04:00
|
|
|
_art_unlocks.reset_to_defaults()
|
2026-08-02 19:01:16 -04:00
|
|
|
_experience.reset_to_defaults()
|
|
|
|
|
_world_time.restore_persistent_time_hours(
|
|
|
|
|
WorldTimeServiceType.DEFAULT_START_HOUR
|
|
|
|
|
)
|
2026-08-03 00:43:43 -04:00
|
|
|
_world_weather.reset_persistent_state()
|
|
|
|
|
_jobs.reset_to_defaults()
|
2026-07-25 18:20:49 -04:00
|
|
|
_is_restoring = false
|
|
|
|
|
_is_dirty = false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _read_integer(
|
|
|
|
|
value: Variant,
|
|
|
|
|
invalid_value: int,
|
|
|
|
|
maximum_value: int = 2147483647,
|
|
|
|
|
) -> int:
|
|
|
|
|
if typeof(value) == TYPE_INT:
|
|
|
|
|
var integer_value: int = int(value)
|
|
|
|
|
if integer_value >= 0 and integer_value <= maximum_value:
|
|
|
|
|
return integer_value
|
|
|
|
|
elif typeof(value) == TYPE_FLOAT:
|
|
|
|
|
var float_value: float = float(value)
|
|
|
|
|
if (
|
|
|
|
|
is_finite(float_value)
|
|
|
|
|
and float_value >= 0.0
|
|
|
|
|
and float_value <= float(maximum_value)
|
|
|
|
|
and is_equal_approx(float_value, round(float_value))
|
|
|
|
|
):
|
|
|
|
|
return int(round(float_value))
|
|
|
|
|
return invalid_value
|
|
|
|
|
|
|
|
|
|
|
2026-08-02 19:01:16 -04:00
|
|
|
func _read_world_time_hours(value: Variant) -> float:
|
|
|
|
|
if typeof(value) not in [TYPE_FLOAT, TYPE_INT]:
|
|
|
|
|
return -1.0
|
|
|
|
|
var time_hours: float = float(value)
|
|
|
|
|
if (
|
|
|
|
|
not is_finite(time_hours)
|
|
|
|
|
or time_hours < 0.0
|
|
|
|
|
or time_hours >= WorldTimeServiceType.HOURS_PER_DAY
|
|
|
|
|
):
|
|
|
|
|
return -1.0
|
|
|
|
|
return time_hours
|
|
|
|
|
|
|
|
|
|
|
2026-08-03 00:43:43 -04:00
|
|
|
func _read_world_weather_seconds(value: Variant) -> float:
|
|
|
|
|
if typeof(value) not in [TYPE_FLOAT, TYPE_INT]:
|
|
|
|
|
return -1.0
|
|
|
|
|
var seconds_remaining: float = float(value)
|
|
|
|
|
if (
|
|
|
|
|
not is_finite(seconds_remaining)
|
|
|
|
|
or seconds_remaining < 0.0
|
|
|
|
|
or seconds_remaining > WorldWeatherServiceType.MAX_PERSISTED_SECONDS
|
|
|
|
|
):
|
|
|
|
|
return -1.0
|
|
|
|
|
return seconds_remaining
|
|
|
|
|
|
|
|
|
|
|
2026-07-25 21:24:01 -04:00
|
|
|
func _read_upgrade_level(
|
|
|
|
|
value: Variant,
|
|
|
|
|
maximum_level: int,
|
|
|
|
|
field_name: String,
|
|
|
|
|
) -> int:
|
|
|
|
|
var parsed_value: int = 0
|
|
|
|
|
var value_is_integer_like: bool = false
|
|
|
|
|
if typeof(value) == TYPE_INT:
|
|
|
|
|
parsed_value = int(value)
|
|
|
|
|
value_is_integer_like = true
|
|
|
|
|
elif typeof(value) == TYPE_FLOAT:
|
|
|
|
|
var float_value: float = float(value)
|
|
|
|
|
if is_finite(float_value) and is_equal_approx(
|
|
|
|
|
float_value,
|
|
|
|
|
round(float_value)
|
|
|
|
|
):
|
|
|
|
|
parsed_value = int(round(float_value))
|
|
|
|
|
value_is_integer_like = true
|
|
|
|
|
if not value_is_integer_like:
|
|
|
|
|
push_warning(
|
|
|
|
|
"Saved upgrade '%s' was invalid; using level 0." % field_name
|
|
|
|
|
)
|
|
|
|
|
return 0
|
|
|
|
|
if parsed_value < 0:
|
|
|
|
|
push_warning(
|
|
|
|
|
"Saved upgrade '%s' was below 0; clamped to 0." % field_name
|
|
|
|
|
)
|
|
|
|
|
return 0
|
|
|
|
|
if parsed_value > maximum_level:
|
|
|
|
|
push_warning(
|
|
|
|
|
(
|
|
|
|
|
"Saved upgrade '%s' exceeded the supported maximum; "
|
|
|
|
|
+ "clamped to %d."
|
|
|
|
|
)
|
|
|
|
|
% [field_name, maximum_level]
|
|
|
|
|
)
|
|
|
|
|
return maximum_level
|
|
|
|
|
return parsed_value
|
|
|
|
|
|
|
|
|
|
|
2026-07-25 18:20:49 -04:00
|
|
|
func _rename_file(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:
|
|
|
|
|
if not FileAccess.file_exists(path):
|
|
|
|
|
return true
|
|
|
|
|
return DirAccess.remove_absolute(
|
|
|
|
|
ProjectSettings.globalize_path(path)
|
|
|
|
|
) == OK
|