extends Node ## D-085 (#258): Game session lifecycle manager. ## Creates per-game save directories on New Game, resumes existing sessions, ## and handles quit-to-menu flow with save confirmation. ## ## All save dirs live under user://saves// where game-id is ## -- (e.g. "20260225-143022-a7b3f1"). const SAVES_DIR := "user://saves/" const GAME_SCENE := "res://scenes/main.tscn" const MENU_SCENE := "res://scenes/main_menu.tscn" var _quit_dialog: ConfirmationDialog = null ## Generate a new game-id, create its save directory, and activate the session. ## Returns the new game-id string. func new_game() -> String: var now := Time.get_datetime_dict_from_system() var timestamp := "%04d%02d%02d-%02d%02d%02d" % [ now.year, now.month, now.day, now.hour, now.minute, now.second, ] var rng := RandomNumberGenerator.new() var hex_seed := "%06x" % (rng.randi() & 0xFFFFFF) var game_id := "%s-%s" % [timestamp, hex_seed] var save_path := SAVES_DIR + game_id + "/" var err := DirAccess.make_dir_recursive_absolute(save_path) if err != OK: push_error("SessionManager: failed to create save dir %s: %s" % [ save_path, error_string(err)]) return "" GameState.current_game_id = game_id # #175: Generate world_seed for deterministic simulation (D-010, D-029). # Combines two randi() calls (u32 each) into 63-bit entropy range. # Mask bit 31 of the upper word before shifting to prevent signed overflow: # GDScript int is i64 — if bit 63 is set, MessagePack encodes as negative, # and Rust rmp_serde rejects negative values when deserializing as u64. GameState.world_seed = ((rng.randi() & 0x7FFFFFFF) << 32) | rng.randi() # Persist world_seed to save directory so resume_game() can restore it. # Without this, loaded sessions would send seed=0, breaking D-010 determinism. _write_seed_file(save_path, GameState.world_seed) return game_id ## Resume an existing game session by setting the active game-id. ## Restores world_seed and character_archetype from the save directory. func resume_game(game_id: String) -> void: GameState.current_game_id = game_id var save_path := SAVES_DIR + game_id + "/" GameState.world_seed = _read_seed_file(save_path) GameState.character_archetype = _read_archetype_file(save_path) ## List all game directories under user://saves/ sorted by last-modified (most recent first). ## Returns Array of {game_id: String, modified_time: int, newest_save: String}. func list_game_dirs() -> Array: var dir := DirAccess.open(SAVES_DIR) if dir == null: return [] var results: Array = [] dir.list_dir_begin() var entry := dir.get_next() while entry != "": if dir.current_is_dir() and not entry.begins_with("."): var dir_path := SAVES_DIR + entry + "/" var newest_save := _find_newest_save(dir_path) var mtime: int = 0 if newest_save != "": mtime = FileAccess.get_modified_time(dir_path + newest_save) results.append({ "game_id": entry, "modified_time": mtime, "newest_save": newest_save, }) entry = dir.get_next() dir.list_dir_end() results.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a.modified_time > b.modified_time) return results ## Show "Save before quitting?" confirmation dialog, then return to main menu. ## #554: The actual F5 save will be wired here once server supports SaveCommand. func quit_to_menu() -> void: if _quit_dialog != null and is_instance_valid(_quit_dialog): return # Dialog already open _quit_dialog = ConfirmationDialog.new() _quit_dialog.dialog_text = UIStrings.get_text("menu.confirm_quit") _quit_dialog.ok_button_text = UIStrings.get_text("menu.confirm_yes") _quit_dialog.cancel_button_text = UIStrings.get_text("menu.confirm_no") get_tree().root.add_child(_quit_dialog) _quit_dialog.confirmed.connect(_do_quit_to_menu) _quit_dialog.canceled.connect(_cleanup_quit_dialog) _quit_dialog.popup_centered() func _do_quit_to_menu() -> void: _cleanup_quit_dialog() # #554: Trigger quicksave before navigating to menu. # send_input() buffers the command — defer scene change by one frame so # SimBridge._process() flushes the outbound buffer before teardown. if not GameState.current_game_id.is_empty(): var path := "user://saves/" + GameState.current_game_id + "/quicksave.sav" SimBridge.send_input({ "action": InputMapper.Action.SAVE_GAME, "timestamp_msec": Time.get_ticks_msec(), "action_data": {"path": path}, }) GameState.current_game_id = "" _navigate_to_menu.call_deferred() else: GameState.current_game_id = "" get_tree().change_scene_to_file(MENU_SCENE) func _navigate_to_menu() -> void: get_tree().change_scene_to_file(MENU_SCENE) func _cleanup_quit_dialog() -> void: if _quit_dialog != null and is_instance_valid(_quit_dialog): _quit_dialog.queue_free() _quit_dialog = null ## Write world_seed to a file in the save directory for session persistence. func _write_seed_file(save_path: String, seed: int) -> void: var file := FileAccess.open(save_path + "world_seed", FileAccess.WRITE) if file == null: push_error("SessionManager: failed to write seed file: %s" % error_string(FileAccess.get_open_error())) return file.store_64(seed) ## Read world_seed from save directory. Returns 0 if file missing (legacy saves). ## Masks the sign bit on read: save files written before the signed-overflow fix ## may contain negative i64 values that Rust rmp_serde rejects as u64. func _read_seed_file(save_path: String) -> int: var file := FileAccess.open(save_path + "world_seed", FileAccess.READ) if file == null: push_warning("SessionManager: no seed file in %s — using seed=0 (legacy save)" % save_path) return 0 return file.get_64() & 0x7FFFFFFFFFFFFFFF ## Write character_archetype to save directory. Called after new_game() creates the dir. func save_character_archetype(game_id: String, archetype: String) -> void: var save_path := SAVES_DIR + game_id + "/" var file := FileAccess.open(save_path + "character.txt", FileAccess.WRITE) if file == null: push_error("SessionManager: failed to write character.txt: %s" % error_string(FileAccess.get_open_error())) return file.store_string(archetype) ## Read character_archetype from save directory. Returns "detective" if missing (legacy saves). func _read_archetype_file(save_path: String) -> String: var file := FileAccess.open(save_path + "character.txt", FileAccess.READ) if file == null: return "detective" return file.get_as_text().strip_edges() func _find_newest_save(dir_path: String) -> String: var dir := DirAccess.open(dir_path) if dir == null: return "" var best_name := "" var best_time: int = 0 dir.list_dir_begin() var entry := dir.get_next() while entry != "": if not dir.current_is_dir() and entry.ends_with(".sav"): var mtime := FileAccess.get_modified_time(dir_path + entry) if mtime > best_time: best_time = mtime best_name = entry entry = dir.get_next() dir.list_dir_end() return best_name