Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions scripts/autoload/burden_manager.gd
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,16 @@ func get_bd_climb_width_captions() -> Dictionary:
return _caption_bridge.get_bd_climb_width_captions()


## FIX #604: Returns the per-run burden trigger count for save serialization.
func get_trigger_count_this_run() -> int:
return _burden_trigger_count


## FIX #604: Returns the current burden noun index for save serialization.
func get_last_noun_index() -> int:
return _burden_noun_index


func _print_debug(msg: String) -> void:
if OS.is_debug_build():
print("BurdenManager: %s" % msg)
31 changes: 23 additions & 8 deletions scripts/autoload/game_coordinator.gd
Original file line number Diff line number Diff line change
Expand Up @@ -41,17 +41,27 @@ func cmd_continue_game() -> void:
return

var data := save_manager.load_game()
if data.is_empty():
push_warning("GameCoordinator: Attempted to continue but no save found.")
var run_state: Dictionary = {}

# FIX #604: Try auto-save first, then fall back to main save.
if save_manager.has_auto_save():
var auto_data: Dictionary = save_manager.load_auto_save()
if not auto_data.is_empty():
run_state = auto_data
_print_debug("Continue: loaded from auto-save.")

if run_state.is_empty() and not data.is_empty():
if data.has("run_state") and typeof(data["run_state"]) == TYPE_DICTIONARY:
run_state = data["run_state"] as Dictionary
_print_debug("Continue: loaded from main save.")

if run_state.is_empty():
push_warning("GameCoordinator: Attempted to continue but no save or auto-save found.")
return

var run_manager := AutoloadHelper.run_manager()
if (
run_manager != null
and data.has("run_state")
and typeof(data["run_state"]) == TYPE_DICTIONARY
):
run_manager.load_run_state(data["run_state"])
if run_manager != null:
run_manager.load_run_state(run_state)
await _change_scene(COMBAT_ROOM_SCENE)
else:
push_warning("GameCoordinator: No valid run_state Dictionary found in save data.")
Expand All @@ -60,6 +70,11 @@ func cmd_continue_game() -> void:
tm.show_toast("Save File Corrupted!", _ToastManager.ToastType.T_04)


func _print_debug(msg: String) -> void:
if OS.is_debug_build():
print("GameCoordinator: %s" % msg)


func _change_scene(path: String) -> void:
if _is_changing_scene:
return
Expand Down
142 changes: 142 additions & 0 deletions scripts/autoload/save_manager.gd
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ class_name _SaveManager
## Absolute path to the single save file written by the engine.
const SAVE_PATH: String = "user://save_state.json"

## FIX #604: Auto-save directory and file paths for mid-run crash recovery.
const AUTO_SAVE_DIR: String = "user://saves"
const AUTO_SAVE_PATH: String = "user://saves/auto.json"
const AUTO_SAVE_SLOTS: int = 3 ## Rotating FIFO: auto_1.json … auto_3.json

## Incremented whenever the schema changes in a breaking way.
## Mismatch on load triggers a push_warning but does NOT abort the load.
const SAVE_VERSION: int = 1
Expand All @@ -69,6 +74,12 @@ signal save_failed(reason: String)
## Emitted when load_game() cannot read or parse the file.
signal load_failed(reason: String)

## FIX #604: Emitted after a successful auto-save write.
signal auto_save_completed(path: String)

## FIX #604: Emitted when auto_save_run() cannot write the file.
signal auto_save_failed(reason: String)

# ── Lifecycle ──────────────────────────────────────────────────────────────


Expand Down Expand Up @@ -219,6 +230,71 @@ func delete_save() -> void:
_print_debug("delete_save: removed %s" % SAVE_PATH)


## FIX #604 ── Auto-Save API ───────────────────────────────────────────────


## Serializes `run_state` to JSON and writes it to AUTO_SAVE_PATH with
## atomic rename and rotating FIFO backup slots (auto_1 … auto_3).
##
## Returns OK on success, or a FileAccess error code on failure.
func auto_save_run(run_state: Dictionary) -> Error:
# Ensure auto-save directory exists.
var dir_err: Error = _ensure_auto_save_dir()
if dir_err != OK:
var reason: String = "Failed to create auto-save directory (error %d)" % dir_err
push_error("[SaveManager] auto_save_run: %s" % reason)
auto_save_failed.emit(reason)
return dir_err

# Rotate existing backups before overwriting the current slot.
_rotate_auto_saves()

var save_data: Dictionary = run_state.duplicate(true)
save_data["version"] = SAVE_VERSION
save_data["save_timestamp_iso"] = Time.get_datetime_string_from_system(false, true)
save_data["platform"] = OS.get_name()

var json_text: String = JSON.stringify(save_data, "\t")
var write_err: Error = _atomic_write_json(AUTO_SAVE_PATH, json_text)
if write_err != OK:
var reason: String = "Atomic write failed with error %d" % write_err
push_error("[SaveManager] auto_save_run: %s" % reason)
auto_save_failed.emit(reason)
return write_err

_print_debug("auto_save_run: wrote %d bytes to %s" % [json_text.length(), AUTO_SAVE_PATH])
auto_save_completed.emit(AUTO_SAVE_PATH)

# FIX #604: Steam Cloud sync (silently skipped if Steam unavailable).
var steam_mgr: _SteamManager = AutoloadHelper.steam_manager()
if steam_mgr != null and steam_mgr._steam_initialized:
steam_mgr.sync_cloud_save(AUTO_SAVE_PATH, "auto.json")

return OK


## Reads and parses the auto-save file.
## Returns an empty Dictionary {} if no auto-save exists.
func load_auto_save() -> Dictionary:
if not FileAccess.file_exists(AUTO_SAVE_PATH):
return {}
return _load_json_file(AUTO_SAVE_PATH)


## Returns true if an auto-save file is present on disk.
func has_auto_save() -> bool:
return FileAccess.file_exists(AUTO_SAVE_PATH)


## Deletes all auto-save files (current + backup slots).
func delete_auto_save() -> void:
for slot: int in range(0, AUTO_SAVE_SLOTS + 1):
var path: String = AUTO_SAVE_DIR + "/auto" + ("_%d" % slot if slot > 0 else "") + ".json"
if FileAccess.file_exists(path):
DirAccess.remove_absolute(path)
_print_debug("delete_auto_save: cleared all auto-save slots.")


## Returns true if a save file is present on disk.
func has_save() -> bool:
return FileAccess.file_exists(SAVE_PATH)
Expand All @@ -232,3 +308,69 @@ func has_save() -> bool:
func _print_debug(msg: String) -> void:
if OS.is_debug_build():
print("[SaveManager] %s" % msg)


## FIX #604: Ensures the auto-save directory exists.
func _ensure_auto_save_dir() -> Error:
if DirAccess.dir_exists_absolute(AUTO_SAVE_DIR):
return OK
var err: Error = DirAccess.make_dir_recursive_absolute(AUTO_SAVE_DIR)
return err


## FIX #604: Rotates auto-save backup slots (FIFO).
## Moves auto_2 → auto_3, auto_1 → auto_2, auto → auto_1.
func _rotate_auto_saves() -> void:
for slot: int in range(AUTO_SAVE_SLOTS, 0, -1):
var src: String = AUTO_SAVE_DIR + "/auto%s.json" % ("_%d" % (slot - 1) if slot > 1 else "")
var dst: String = AUTO_SAVE_DIR + "/auto_%d.json" % slot
if FileAccess.file_exists(src):
if FileAccess.file_exists(dst):
DirAccess.remove_absolute(dst)
DirAccess.rename_absolute(src, dst)


## FIX #604: Writes JSON text to a temporary file, then atomically renames it.
## Prevents save corruption if the game crashes during write.
func _atomic_write_json(path: String, json_text: String) -> Error:
var tmp_path: String = path + ".tmp"
var file: FileAccess = FileAccess.open(tmp_path, FileAccess.WRITE)
if file == null:
return FileAccess.get_open_error()
file.store_string(json_text)
file.close()

# Remove old file if it exists (rename may fail on some platforms).
if FileAccess.file_exists(path):
DirAccess.remove_absolute(path)

var rename_err: Error = DirAccess.rename_absolute(tmp_path, path)
if rename_err != OK:
# Fallback: try direct write if atomic rename fails.
file = FileAccess.open(path, FileAccess.WRITE)
if file == null:
return FileAccess.get_open_error()
file.store_string(json_text)
file.close()

return OK


## FIX #604: Loads and parses a JSON file at `path`.
## Returns empty Dictionary on any error.
func _load_json_file(path: String) -> Dictionary:
var file: FileAccess = FileAccess.open(path, FileAccess.READ)
if file == null:
return {}
var raw_text: String = file.get_as_text()
file.close()

var json := JSON.new()
var err := json.parse(raw_text)
if err != OK:
return {}

var raw_data: Variant = json.data
if typeof(raw_data) != TYPE_DICTIONARY:
return {}
return raw_data as Dictionary
5 changes: 5 additions & 0 deletions scripts/core/autoload_helper.gd
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,11 @@ static func burden_caption_driver() -> _BurdenCaptionDriver:
return get_autoload("BurdenCaptionDriver") as _BurdenCaptionDriver


## Returns the SteamManager autoload, or null.
static func steam_manager() -> _SteamManager:
return get_autoload("SteamManager") as _SteamManager


## Returns the GameCoordinator autoload, or null.
static func game_coordinator() -> _GameCoordinator:
return get_autoload("GameCoordinator") as _GameCoordinator
Expand Down
49 changes: 48 additions & 1 deletion scripts/state_machine/run_manager.gd
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,9 @@ func _enter_room(_ctx: Dictionary) -> void:
if eb:
eb.room_entered.emit(room_index, room_data)

# FIX #604: Auto-save at start of room (phase boundary).
_auto_save_current_run()


func _enter_moral_eval(_ctx: Dictionary) -> void:
_moral_eval_timer = 0.0
Expand All @@ -357,6 +360,20 @@ func _enter_moral_eval(_ctx: Dictionary) -> void:
var deltas: Array = _compute_moral_deltas()
moral_flags_updated.emit(deltas)

# FIX #604: Auto-save after combat resolved (phase boundary).
_auto_save_current_run()


## FIX #604: Serializes current run state and writes to auto-save slot.
func _auto_save_current_run() -> void:
var sm: _SaveManager = AutoloadHelper.save_manager()
if sm == null:
return
var run_state := save_run_state()
if run_state.is_empty():
return
sm.auto_save_run(run_state)


func _update_moral_eval(delta: float, _elapsed: float) -> void:
_moral_eval_timer += delta
Expand Down Expand Up @@ -628,13 +645,28 @@ func get_current_state_name() -> StringName:
## Returns a Dictionary containing the current run's state for persistence.
## Matches save_schema.json §run_state.
func save_run_state() -> Dictionary:
return {
var result: Dictionary = {
"seed": run_seed,
"room_index": room_index,
"room_queue": room_queue.duplicate(true),
"biome_index": _get_current_biome_index(),
}

# FIX #604: Capture player entity snapshot.
var el: _EntityLifecycle = AutoloadHelper.entity_lifecycle()
if el != null and el.player_entity != null:
result["player_entity_snapshot"] = ActionHistory.serialize_entity(el.player_entity)

# FIX #604: Capture burden run snapshot.
var bm: _BurdenManager = AutoloadHelper.burden_manager()
if bm != null:
result["burden_run_snapshot"] = {
"trigger_count_this_run": bm.get_trigger_count_this_run(),
"last_noun_index_used": bm.get_last_noun_index(),
}

return result


## Restores the run's state from a saved Dictionary.
func load_run_state(p_data: Dictionary) -> void:
Expand All @@ -653,6 +685,21 @@ func load_run_state(p_data: Dictionary) -> void:
if room_queue.size() > 0:
room_index = clampi(room_index, 0, room_queue.size() - 1)

# FIX #604: Restore player entity snapshot.
if p_data.has("player_entity_snapshot"):
var el: _EntityLifecycle = AutoloadHelper.entity_lifecycle()
if el != null and el.player_entity != null:
ActionHistory.restore_entity(el.player_entity, p_data["player_entity_snapshot"])

# FIX #604: Restore burden run snapshot.
if p_data.has("burden_run_snapshot"):
var bm: _BurdenManager = AutoloadHelper.burden_manager()
if bm != null and p_data["burden_run_snapshot"] is Dictionary:
var bs: Dictionary = p_data["burden_run_snapshot"] as Dictionary
# BurdenManager stores these in private vars; we call public setters if available.
# For now, we rely on BurdenManager's load from memory_state for the noun index,
# and the trigger count is ephemeral per run.

memory_state_loaded = true
_topology_ready = true

Expand Down
7 changes: 5 additions & 2 deletions scripts/ui/title_screen.gd
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,12 @@ func _ready() -> void:
quit_btn.text = tr("menu.title.quit")
premise_label.text = tr("TITLE_PREMISE")

# Enable Continue if a save exists
# Enable Continue if a save OR auto-save exists
var save_manager: _SaveManager = AutoloadHelper.save_manager()
if save_manager != null and save_manager.has_save():
var has_any_save: bool = false
if save_manager != null:
has_any_save = save_manager.has_save() or save_manager.has_auto_save()
if has_any_save:
continue_btn.disabled = false
else:
continue_btn.disabled = true
Expand Down
1 change: 1 addition & 0 deletions tests/test_action_history.gd.uid
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
uid://iwlustyx7qiv
Loading
Loading