From 8d92f122f8f66616ceb84625384e630e44758dc4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Feb 2026 09:54:06 +0000 Subject: [PATCH 1/6] Initial plan From e5cb1864bb9a08506a65c2e8373b158de5365f39 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Feb 2026 09:58:05 +0000 Subject: [PATCH 2/6] Implement multiple save files with SaveFileManager Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com> --- src/fishE.py | 129 ++++++++++++++++--- src/saveFileManager.py | 112 +++++++++++++++++ tests/test_fishE.py | 16 ++- tests/test_saveFileManager.py | 229 ++++++++++++++++++++++++++++++++++ 4 files changed, 469 insertions(+), 17 deletions(-) create mode 100644 src/saveFileManager.py create mode 100644 tests/test_saveFileManager.py diff --git a/src/fishE.py b/src/fishE.py index bae3b3b..5108ab9 100644 --- a/src/fishE.py +++ b/src/fishE.py @@ -9,6 +9,7 @@ from world.timeService import TimeService from stats.stats import Stats from ui.userInterface import UserInterface +from saveFileManager import SaveFileManager # @author Daniel McCoy Stephenson @@ -19,27 +20,28 @@ def __init__(self): self.playerJsonReaderWriter = PlayerJsonReaderWriter() self.timeServiceJsonReaderWriter = TimeServiceJsonReaderWriter() self.statsJsonReaderWriter = StatsJsonReaderWriter() + self.saveFileManager = SaveFileManager() + + # Show save file selection menu + self._selectSaveFile() # if save file exists, load it - if ( - os.path.exists("data/player.json") - and os.path.getsize("data/player.json") > 0 - ): + player_path = self.saveFileManager.get_save_path("player.json") + if os.path.exists(player_path) and os.path.getsize(player_path) > 0: self.loadPlayer() else: self.player = Player() # if save file exists, load it - if os.path.exists("data/stats.json") and os.path.getsize("data/stats.json") > 0: + stats_path = self.saveFileManager.get_save_path("stats.json") + if os.path.exists(stats_path) and os.path.getsize(stats_path) > 0: self.loadStats() else: self.stats = Stats() # if save file exists, load it - if ( - os.path.exists("data/timeService.json") - and os.path.getsize("data/timeService.json") > 0 - ): + time_path = self.saveFileManager.get_save_path("timeService.json") + if os.path.exists(time_path) and os.path.getsize(time_path) > 0: self.loadTimeService() else: self.timeService = TimeService(self.player, self.stats) @@ -88,6 +90,96 @@ def __init__(self): self.currentLocation = LocationType.HOME + def _selectSaveFile(self): + """Display save file selection menu and let user choose""" + save_files = self.saveFileManager.list_save_files() + + print("\n" * 20) + print("-" * 75) + print("\n FISHE - SAVE FILE MANAGER") + print("-" * 75) + + if save_files: + print("\n Available Save Files:\n") + for save in save_files: + metadata = save["metadata"] + print(f" [{save['slot']}] Save Slot {save['slot']}") + print(f" Day: {metadata.get('day', 1)}") + print(f" Money: ${metadata.get('money', 0)}") + print(f" Fish: {metadata.get('fishCount', 0)}") + print(f" Last Modified: {metadata.get('last_modified', 'Unknown')}") + print() + + next_slot = self.saveFileManager.get_next_available_slot() + print(f" [N] Create New Save (Slot {next_slot})") + if save_files: + print(" [D] Delete a Save File") + print(" [Q] Quit") + print("-" * 75) + + while True: + choice = input("\n Select an option: ").strip().upper() + + if choice == "Q": + print("\n Goodbye!") + exit(0) + elif choice == "N": + self.saveFileManager.select_save_slot(next_slot) + print(f"\n Creating new save in Slot {next_slot}...") + break + elif choice == "D" and save_files: + self._deleteSaveFile(save_files) + # Recursively call to show updated menu + self._selectSaveFile() + return + elif choice.isdigit(): + slot_num = int(choice) + if any(save["slot"] == slot_num for save in save_files): + self.saveFileManager.select_save_slot(slot_num) + print(f"\n Loading Save Slot {slot_num}...") + break + else: + print(" Invalid slot number. Try again.") + else: + print(" Invalid choice. Try again.") + + def _deleteSaveFile(self, save_files): + """Delete a save file""" + print("\n" * 20) + print("-" * 75) + print("\n DELETE SAVE FILE") + print("-" * 75) + print("\n Which save file would you like to delete?\n") + + for save in save_files: + print(f" [{save['slot']}] Save Slot {save['slot']}") + + print(" [C] Cancel") + print("-" * 75) + + while True: + choice = input("\n Select a slot to delete: ").strip().upper() + + if choice == "C": + return + elif choice.isdigit(): + slot_num = int(choice) + if any(save["slot"] == slot_num for save in save_files): + confirm = input(f"\n Are you sure you want to delete Slot {slot_num}? (Y/N): ").strip().upper() + if confirm == "Y": + if self.saveFileManager.delete_save_slot(slot_num): + print(f"\n Slot {slot_num} deleted successfully.") + input("\n [ CONTINUE ]") + return + else: + print(f"\n Failed to delete Slot {slot_num}.") + else: + return + else: + print(" Invalid slot number. Try again.") + else: + print(" Invalid choice. Try again.") + def play(self): while self.running: # change location @@ -107,29 +199,36 @@ def save(self): if not os.path.exists("data"): os.makedirs("data") - playerSaveFile = open("data/player.json", "w") + playerSaveFile = open(self.saveFileManager.get_save_path("player.json"), "w") self.playerJsonReaderWriter.writePlayerToFile(self.player, playerSaveFile) + playerSaveFile.close() - timeServiceSaveFile = open("data/timeService.json", "w") + timeServiceSaveFile = open( + self.saveFileManager.get_save_path("timeService.json"), "w" + ) self.timeServiceJsonReaderWriter.writeTimeServiceToFile( self.timeService, timeServiceSaveFile ) + timeServiceSaveFile.close() - statsSaveFile = open("data/stats.json", "w") + statsSaveFile = open(self.saveFileManager.get_save_path("stats.json"), "w") self.statsJsonReaderWriter.writeStatsToFile(self.stats, statsSaveFile) + statsSaveFile.close() def loadPlayer(self): - playerSaveFile = open("data/player.json", "r") + playerSaveFile = open(self.saveFileManager.get_save_path("player.json"), "r") self.player = self.playerJsonReaderWriter.readPlayerFromFile(playerSaveFile) playerSaveFile.close() def loadStats(self): - statsSaveFile = open("data/stats.json", "r") + statsSaveFile = open(self.saveFileManager.get_save_path("stats.json"), "r") self.stats = self.statsJsonReaderWriter.readStatsFromFile(statsSaveFile) statsSaveFile.close() def loadTimeService(self): - timeServiceSaveFile = open("data/timeService.json", "r") + timeServiceSaveFile = open( + self.saveFileManager.get_save_path("timeService.json"), "r" + ) self.timeService = self.timeServiceJsonReaderWriter.readTimeServiceFromFile( timeServiceSaveFile, self.player, self.stats ) diff --git a/src/saveFileManager.py b/src/saveFileManager.py new file mode 100644 index 0000000..371d198 --- /dev/null +++ b/src/saveFileManager.py @@ -0,0 +1,112 @@ +import os +import json +from datetime import datetime + + +# @author Daniel McCoy Stephenson +class SaveFileManager: + """Manages multiple save files for the game""" + + def __init__(self, data_directory="data"): + self.data_directory = data_directory + self.selected_save_slot = None + + def list_save_files(self): + """Returns a list of available save file slots with their metadata""" + if not os.path.exists(self.data_directory): + return [] + + save_files = [] + # Look for save slots (slot_1, slot_2, etc.) + for i in range(1, 100): # Support up to 99 save slots + slot_name = f"slot_{i}" + slot_path = os.path.join(self.data_directory, slot_name) + if os.path.exists(slot_path): + metadata = self._read_save_metadata(slot_path) + if metadata: + save_files.append( + { + "slot": i, + "slot_name": slot_name, + "path": slot_path, + "metadata": metadata, + } + ) + return save_files + + def _read_save_metadata(self, slot_path): + """Read metadata from a save slot""" + try: + player_file = os.path.join(slot_path, "player.json") + time_file = os.path.join(slot_path, "timeService.json") + + if not os.path.exists(player_file): + return None + + metadata = {} + + # Read player data + if os.path.exists(player_file) and os.path.getsize(player_file) > 0: + with open(player_file, "r") as f: + player_data = json.load(f) + metadata["money"] = player_data.get("money", 0) + metadata["fishCount"] = player_data.get("fishCount", 0) + metadata["energy"] = player_data.get("energy", 100) + + # Read time data + if os.path.exists(time_file) and os.path.getsize(time_file) > 0: + with open(time_file, "r") as f: + time_data = json.load(f) + metadata["day"] = time_data.get("day", 1) + metadata["time"] = time_data.get("time", 0) + + # Get last modified time + metadata["last_modified"] = datetime.fromtimestamp( + os.path.getmtime(player_file) + ).strftime("%Y-%m-%d %H:%M:%S") + + return metadata + except Exception: + return None + + def get_next_available_slot(self): + """Returns the next available save slot number""" + save_files = self.list_save_files() + if not save_files: + return 1 + + # Find gaps in slot numbers + existing_slots = sorted([save["slot"] for save in save_files]) + for i in range(1, 100): + if i not in existing_slots: + return i + return len(existing_slots) + 1 + + def select_save_slot(self, slot_number): + """Select a save slot to use""" + self.selected_save_slot = slot_number + + def get_save_path(self, filename): + """Get the full path for a save file in the selected slot""" + if self.selected_save_slot is None: + raise ValueError("No save slot selected") + + slot_name = f"slot_{self.selected_save_slot}" + slot_path = os.path.join(self.data_directory, slot_name) + + # Create slot directory if it doesn't exist + if not os.path.exists(slot_path): + os.makedirs(slot_path) + + return os.path.join(slot_path, filename) + + def delete_save_slot(self, slot_number): + """Delete a save slot""" + slot_name = f"slot_{slot_number}" + slot_path = os.path.join(self.data_directory, slot_name) + + if os.path.exists(slot_path): + import shutil + shutil.rmtree(slot_path) + return True + return False diff --git a/tests/test_fishE.py b/tests/test_fishE.py index ca3f0aa..b83a542 100644 --- a/tests/test_fishE.py +++ b/tests/test_fishE.py @@ -1,4 +1,4 @@ -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from src import fishE @@ -16,10 +16,21 @@ def createFishE(): fishE.PlayerJsonReaderWriter = MagicMock() fishE.TimeServiceJsonReaderWriter = MagicMock() fishE.StatsJsonReaderWriter = MagicMock() + fishE.SaveFileManager = MagicMock() fishE.loadPlayer = MagicMock() fishE.loadStats = MagicMock() fishE.loadTimeService = MagicMock() - return fishE.FishE() + + # Mock the save file manager instance methods + mock_save_manager = MagicMock() + mock_save_manager.get_save_path.return_value = "data/player.json" + mock_save_manager.list_save_files.return_value = [] + mock_save_manager.get_next_available_slot.return_value = 1 + fishE.SaveFileManager.return_value = mock_save_manager + + # Mock the _selectSaveFile method to avoid stdin interaction + with patch.object(fishE.FishE, '_selectSaveFile', return_value=None): + return fishE.FishE() def test_initialization(): @@ -51,3 +62,4 @@ def test_initialization(): fishE.PlayerJsonReaderWriter.assert_called_once() fishE.TimeServiceJsonReaderWriter.assert_called_once() fishE.StatsJsonReaderWriter.assert_called_once() + fishE.SaveFileManager.assert_called_once() diff --git a/tests/test_saveFileManager.py b/tests/test_saveFileManager.py new file mode 100644 index 0000000..6c3efe9 --- /dev/null +++ b/tests/test_saveFileManager.py @@ -0,0 +1,229 @@ +import os +import json +import tempfile +import shutil +from src.saveFileManager import SaveFileManager + + +def test_initialization(): + manager = SaveFileManager() + assert manager.data_directory == "data" + assert manager.selected_save_slot is None + + +def test_initialization_custom_directory(): + manager = SaveFileManager("custom_data") + assert manager.data_directory == "custom_data" + + +def test_list_save_files_empty(): + # Create temp directory + temp_dir = tempfile.mkdtemp() + try: + manager = SaveFileManager(temp_dir) + save_files = manager.list_save_files() + assert save_files == [] + finally: + shutil.rmtree(temp_dir) + + +def test_list_save_files_with_saves(): + # Create temp directory + temp_dir = tempfile.mkdtemp() + try: + manager = SaveFileManager(temp_dir) + + # Create a save slot + slot_path = os.path.join(temp_dir, "slot_1") + os.makedirs(slot_path) + + # Create player.json + player_data = {"money": 100, "fishCount": 5, "energy": 80} + with open(os.path.join(slot_path, "player.json"), "w") as f: + json.dump(player_data, f) + + # Create timeService.json + time_data = {"day": 3, "time": 10} + with open(os.path.join(slot_path, "timeService.json"), "w") as f: + json.dump(time_data, f) + + save_files = manager.list_save_files() + assert len(save_files) == 1 + assert save_files[0]["slot"] == 1 + assert save_files[0]["metadata"]["money"] == 100 + assert save_files[0]["metadata"]["fishCount"] == 5 + assert save_files[0]["metadata"]["day"] == 3 + finally: + shutil.rmtree(temp_dir) + + +def test_get_next_available_slot_empty(): + temp_dir = tempfile.mkdtemp() + try: + manager = SaveFileManager(temp_dir) + next_slot = manager.get_next_available_slot() + assert next_slot == 1 + finally: + shutil.rmtree(temp_dir) + + +def test_get_next_available_slot_with_existing(): + temp_dir = tempfile.mkdtemp() + try: + manager = SaveFileManager(temp_dir) + + # Create slot 1 + slot_path = os.path.join(temp_dir, "slot_1") + os.makedirs(slot_path) + with open(os.path.join(slot_path, "player.json"), "w") as f: + json.dump({"money": 0}, f) + + next_slot = manager.get_next_available_slot() + assert next_slot == 2 + finally: + shutil.rmtree(temp_dir) + + +def test_get_next_available_slot_with_gap(): + temp_dir = tempfile.mkdtemp() + try: + manager = SaveFileManager(temp_dir) + + # Create slot 1 and 3 (gap at 2) + for slot_num in [1, 3]: + slot_path = os.path.join(temp_dir, f"slot_{slot_num}") + os.makedirs(slot_path) + with open(os.path.join(slot_path, "player.json"), "w") as f: + json.dump({"money": 0}, f) + + next_slot = manager.get_next_available_slot() + assert next_slot == 2 + finally: + shutil.rmtree(temp_dir) + + +def test_select_save_slot(): + manager = SaveFileManager() + manager.select_save_slot(5) + assert manager.selected_save_slot == 5 + + +def test_get_save_path(): + temp_dir = tempfile.mkdtemp() + try: + manager = SaveFileManager(temp_dir) + manager.select_save_slot(1) + + path = manager.get_save_path("player.json") + expected = os.path.join(temp_dir, "slot_1", "player.json") + assert path == expected + + # Check that directory was created + assert os.path.exists(os.path.join(temp_dir, "slot_1")) + finally: + shutil.rmtree(temp_dir) + + +def test_get_save_path_no_slot_selected(): + manager = SaveFileManager() + try: + manager.get_save_path("player.json") + assert False, "Should have raised ValueError" + except ValueError as e: + assert "No save slot selected" in str(e) + + +def test_delete_save_slot(): + temp_dir = tempfile.mkdtemp() + try: + manager = SaveFileManager(temp_dir) + + # Create a save slot + slot_path = os.path.join(temp_dir, "slot_1") + os.makedirs(slot_path) + with open(os.path.join(slot_path, "player.json"), "w") as f: + json.dump({"money": 100}, f) + + assert os.path.exists(slot_path) + + # Delete it + result = manager.delete_save_slot(1) + assert result is True + assert not os.path.exists(slot_path) + finally: + shutil.rmtree(temp_dir) + + +def test_delete_nonexistent_save_slot(): + temp_dir = tempfile.mkdtemp() + try: + manager = SaveFileManager(temp_dir) + result = manager.delete_save_slot(99) + assert result is False + finally: + shutil.rmtree(temp_dir) + + +def test_multiple_save_files_dont_conflict(): + temp_dir = tempfile.mkdtemp() + try: + manager = SaveFileManager(temp_dir) + + # Create slot 1 + manager.select_save_slot(1) + path1 = manager.get_save_path("player.json") + with open(path1, "w") as f: + json.dump({"money": 100}, f) + + # Create slot 2 + manager.select_save_slot(2) + path2 = manager.get_save_path("player.json") + with open(path2, "w") as f: + json.dump({"money": 200}, f) + + # Verify both exist and are different + assert os.path.exists(path1) + assert os.path.exists(path2) + assert path1 != path2 + + with open(path1, "r") as f: + data1 = json.load(f) + with open(path2, "r") as f: + data2 = json.load(f) + + assert data1["money"] == 100 + assert data2["money"] == 200 + finally: + shutil.rmtree(temp_dir) + + +def test_read_save_metadata_missing_files(): + temp_dir = tempfile.mkdtemp() + try: + manager = SaveFileManager(temp_dir) + + # Create empty slot directory + slot_path = os.path.join(temp_dir, "slot_1") + os.makedirs(slot_path) + + metadata = manager._read_save_metadata(slot_path) + assert metadata is None + finally: + shutil.rmtree(temp_dir) + + +def test_read_save_metadata_corrupted_json(): + temp_dir = tempfile.mkdtemp() + try: + manager = SaveFileManager(temp_dir) + + # Create slot with corrupted json + slot_path = os.path.join(temp_dir, "slot_1") + os.makedirs(slot_path) + with open(os.path.join(slot_path, "player.json"), "w") as f: + f.write("invalid json{") + + metadata = manager._read_save_metadata(slot_path) + assert metadata is None + finally: + shutil.rmtree(temp_dir) From 710045c747e110f4a3b1cae7cc446fa486dffb6e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Feb 2026 10:00:01 +0000 Subject: [PATCH 3/6] Update .gitignore to exclude data directory Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com> --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index e20213e..aac6909 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ *.pyc __pycache__/ -data/*.json +data/ .coverage cov.xml \ No newline at end of file From 102ceace63fd9456625feb4ef2fb0a6f095112ff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Feb 2026 10:01:21 +0000 Subject: [PATCH 4/6] Address code review feedback - improve imports and test structure Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com> --- src/saveFileManager.py | 2 +- tests/test_saveFileManager.py | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/saveFileManager.py b/src/saveFileManager.py index 371d198..c93b1c9 100644 --- a/src/saveFileManager.py +++ b/src/saveFileManager.py @@ -1,5 +1,6 @@ import os import json +import shutil from datetime import datetime @@ -106,7 +107,6 @@ def delete_save_slot(self, slot_number): slot_path = os.path.join(self.data_directory, slot_name) if os.path.exists(slot_path): - import shutil shutil.rmtree(slot_path) return True return False diff --git a/tests/test_saveFileManager.py b/tests/test_saveFileManager.py index 6c3efe9..5b048ba 100644 --- a/tests/test_saveFileManager.py +++ b/tests/test_saveFileManager.py @@ -2,6 +2,7 @@ import json import tempfile import shutil +import pytest from src.saveFileManager import SaveFileManager @@ -126,11 +127,8 @@ def test_get_save_path(): def test_get_save_path_no_slot_selected(): manager = SaveFileManager() - try: + with pytest.raises(ValueError, match="No save slot selected"): manager.get_save_path("player.json") - assert False, "Should have raised ValueError" - except ValueError as e: - assert "No save slot selected" in str(e) def test_delete_save_slot(): From 1e5ad90b4089b0adddc8e3c48727273e3733231d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Feb 2026 10:02:18 +0000 Subject: [PATCH 5/6] Add multiple save files documentation to README Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com> --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index ca50f94..e5bb4c1 100644 --- a/README.md +++ b/README.md @@ -3,3 +3,15 @@ [![Run Unit Tests](https://github.com/Stephenson-Software/FishE/actions/workflows/test.yml/badge.svg)](https://github.com/Stephenson-Software/FishE/actions/workflows/test.yml) This game allows you to explore a fishing village and perform actions in it. + +## Features + +### Multiple Save Files +FishE supports multiple save files, allowing you to maintain different game progressions simultaneously. When you start the game, you'll see a save file manager that displays: + +- **Existing Saves**: View all your saved games with their progress (Day, Money, Fish count, Last Modified) +- **Create New Save**: Start a fresh game in a new save slot +- **Delete Save**: Remove unwanted save files +- **Quick Load**: Load any existing save file to continue your adventure + +Each save file is stored in its own slot (slot_1, slot_2, etc.) in the `data/` directory, ensuring your saves never conflict with each other. From 6c58c97944051df4104b3710c5b20019dff646e9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Feb 2026 01:43:05 +0000 Subject: [PATCH 6/6] Address PR review comments - improve error handling, fix recursion, add migration Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com> --- src/fishE.py | 155 +++++++++++++++++++--------------- src/saveFileManager.py | 69 ++++++++++++--- tests/test_saveFileManager.py | 59 +++++++++++++ 3 files changed, 205 insertions(+), 78 deletions(-) diff --git a/src/fishE.py b/src/fishE.py index 5108ab9..47855db 100644 --- a/src/fishE.py +++ b/src/fishE.py @@ -1,4 +1,5 @@ import os +import json from location import bank, docks, home, shop, tavern from location.enum.locationType import LocationType from player.player import Player @@ -22,6 +23,9 @@ def __init__(self): self.statsJsonReaderWriter = StatsJsonReaderWriter() self.saveFileManager = SaveFileManager() + # Migrate old save files to new format if they exist + self.saveFileManager.migrate_old_save_files() + # Show save file selection menu self._selectSaveFile() @@ -92,59 +96,64 @@ def __init__(self): def _selectSaveFile(self): """Display save file selection menu and let user choose""" - save_files = self.saveFileManager.list_save_files() - - print("\n" * 20) - print("-" * 75) - print("\n FISHE - SAVE FILE MANAGER") - print("-" * 75) + while True: # Use loop instead of recursion to avoid stack overflow + save_files = self.saveFileManager.list_save_files() + + print("\n" * 20) + print("-" * 75) + print("\n FISHE - SAVE FILE MANAGER") + print("-" * 75) + + if save_files: + print("\n Available Save Files:\n") + for save in save_files: + metadata = save["metadata"] + print(f" [{save['slot']}] Save Slot {save['slot']}") + print(f" Day: {metadata.get('day', 1)}") + print(f" Money: ${metadata.get('money', 0)}") + print(f" Fish: {metadata.get('fishCount', 0)}") + print(f" Last Modified: {metadata.get('last_modified', 'Unknown')}") + print() + + next_slot = self.saveFileManager.get_next_available_slot() + if next_slot is not None: + print(f" [N] Create New Save (Slot {next_slot})") + if save_files: + print(" [D] Delete a Save File") + print(" [Q] Quit") + print("-" * 75) - if save_files: - print("\n Available Save Files:\n") - for save in save_files: - metadata = save["metadata"] - print(f" [{save['slot']}] Save Slot {save['slot']}") - print(f" Day: {metadata.get('day', 1)}") - print(f" Money: ${metadata.get('money', 0)}") - print(f" Fish: {metadata.get('fishCount', 0)}") - print(f" Last Modified: {metadata.get('last_modified', 'Unknown')}") - print() - - next_slot = self.saveFileManager.get_next_available_slot() - print(f" [N] Create New Save (Slot {next_slot})") - if save_files: - print(" [D] Delete a Save File") - print(" [Q] Quit") - print("-" * 75) - - while True: choice = input("\n Select an option: ").strip().upper() if choice == "Q": print("\n Goodbye!") exit(0) - elif choice == "N": + elif choice == "N" and next_slot is not None: self.saveFileManager.select_save_slot(next_slot) print(f"\n Creating new save in Slot {next_slot}...") - break - elif choice == "D" and save_files: - self._deleteSaveFile(save_files) - # Recursively call to show updated menu - self._selectSaveFile() return + elif choice == "N" and next_slot is None: + print(" All save slots are full. Please delete a save first.") + elif choice == "D" and save_files: + if self._deleteSaveFile(save_files): + # Continue loop to show updated menu + continue + else: + # User cancelled, continue loop + continue elif choice.isdigit(): slot_num = int(choice) if any(save["slot"] == slot_num for save in save_files): self.saveFileManager.select_save_slot(slot_num) print(f"\n Loading Save Slot {slot_num}...") - break + return else: print(" Invalid slot number. Try again.") else: print(" Invalid choice. Try again.") def _deleteSaveFile(self, save_files): - """Delete a save file""" + """Delete a save file. Returns True if a file was deleted, False if cancelled.""" print("\n" * 20) print("-" * 75) print("\n DELETE SAVE FILE") @@ -161,7 +170,7 @@ def _deleteSaveFile(self, save_files): choice = input("\n Select a slot to delete: ").strip().upper() if choice == "C": - return + return False elif choice.isdigit(): slot_num = int(choice) if any(save["slot"] == slot_num for save in save_files): @@ -170,11 +179,12 @@ def _deleteSaveFile(self, save_files): if self.saveFileManager.delete_save_slot(slot_num): print(f"\n Slot {slot_num} deleted successfully.") input("\n [ CONTINUE ]") - return + return True else: print(f"\n Failed to delete Slot {slot_num}.") + return False else: - return + return False else: print(" Invalid slot number. Try again.") else: @@ -195,44 +205,53 @@ def play(self): self.save() def save(self): - # create data directory - if not os.path.exists("data"): - os.makedirs("data") - - playerSaveFile = open(self.saveFileManager.get_save_path("player.json"), "w") - self.playerJsonReaderWriter.writePlayerToFile(self.player, playerSaveFile) - playerSaveFile.close() - - timeServiceSaveFile = open( - self.saveFileManager.get_save_path("timeService.json"), "w" - ) - self.timeServiceJsonReaderWriter.writeTimeServiceToFile( - self.timeService, timeServiceSaveFile - ) - timeServiceSaveFile.close() - - statsSaveFile = open(self.saveFileManager.get_save_path("stats.json"), "w") - self.statsJsonReaderWriter.writeStatsToFile(self.stats, statsSaveFile) - statsSaveFile.close() + # create data directory - use SaveFileManager's directory + if not os.path.exists(self.saveFileManager.data_directory): + os.makedirs(self.saveFileManager.data_directory, exist_ok=True) + + try: + with open(self.saveFileManager.get_save_path("player.json"), "w") as playerSaveFile: + self.playerJsonReaderWriter.writePlayerToFile(self.player, playerSaveFile) + + with open(self.saveFileManager.get_save_path("timeService.json"), "w") as timeServiceSaveFile: + self.timeServiceJsonReaderWriter.writeTimeServiceToFile( + self.timeService, timeServiceSaveFile + ) + + with open(self.saveFileManager.get_save_path("stats.json"), "w") as statsSaveFile: + self.statsJsonReaderWriter.writeStatsToFile(self.stats, statsSaveFile) + except (IOError, OSError) as e: + print(f"\n Warning: Failed to save game: {e}") + # Game continues even if save fails def loadPlayer(self): - playerSaveFile = open(self.saveFileManager.get_save_path("player.json"), "r") - self.player = self.playerJsonReaderWriter.readPlayerFromFile(playerSaveFile) - playerSaveFile.close() + try: + with open(self.saveFileManager.get_save_path("player.json"), "r") as playerSaveFile: + self.player = self.playerJsonReaderWriter.readPlayerFromFile(playerSaveFile) + except (IOError, OSError, json.JSONDecodeError) as e: + print(f"\n Warning: Failed to load player data: {e}") + print(" Creating new player...") + self.player = Player() def loadStats(self): - statsSaveFile = open(self.saveFileManager.get_save_path("stats.json"), "r") - self.stats = self.statsJsonReaderWriter.readStatsFromFile(statsSaveFile) - statsSaveFile.close() + try: + with open(self.saveFileManager.get_save_path("stats.json"), "r") as statsSaveFile: + self.stats = self.statsJsonReaderWriter.readStatsFromFile(statsSaveFile) + except (IOError, OSError, json.JSONDecodeError) as e: + print(f"\n Warning: Failed to load stats data: {e}") + print(" Creating new stats...") + self.stats = Stats() def loadTimeService(self): - timeServiceSaveFile = open( - self.saveFileManager.get_save_path("timeService.json"), "r" - ) - self.timeService = self.timeServiceJsonReaderWriter.readTimeServiceFromFile( - timeServiceSaveFile, self.player, self.stats - ) - timeServiceSaveFile.close() + try: + with open(self.saveFileManager.get_save_path("timeService.json"), "r") as timeServiceSaveFile: + self.timeService = self.timeServiceJsonReaderWriter.readTimeServiceFromFile( + timeServiceSaveFile, self.player, self.stats + ) + except (IOError, OSError, json.JSONDecodeError) as e: + print(f"\n Warning: Failed to load time service data: {e}") + print(" Creating new time service...") + self.timeService = TimeService(self.player, self.stats) if __name__ == "__main__": diff --git a/src/saveFileManager.py b/src/saveFileManager.py index c93b1c9..07f727c 100644 --- a/src/saveFileManager.py +++ b/src/saveFileManager.py @@ -18,21 +18,41 @@ def list_save_files(self): return [] save_files = [] - # Look for save slots (slot_1, slot_2, etc.) - for i in range(1, 100): # Support up to 99 save slots - slot_name = f"slot_{i}" - slot_path = os.path.join(self.data_directory, slot_name) - if os.path.exists(slot_path): + # Look for save slots (slot_1, slot_2, etc.) by inspecting existing directories + try: + for entry in os.listdir(self.data_directory): + if not entry.startswith("slot_"): + continue + + # Extract the numeric slot index from the directory name + _, _, suffix = entry.partition("_") + if not suffix.isdigit(): + continue + + slot_index = int(suffix) + if slot_index < 1 or slot_index >= 100: + # Preserve the upper bound of 99 save slots + continue + + slot_name = entry + slot_path = os.path.join(self.data_directory, slot_name) + if not os.path.isdir(slot_path): + continue + metadata = self._read_save_metadata(slot_path) if metadata: save_files.append( { - "slot": i, + "slot": slot_index, "slot_name": slot_name, "path": slot_path, "metadata": metadata, } ) + except OSError: + # If we can't read the directory, return empty list + return [] + return save_files def _read_save_metadata(self, slot_path): @@ -67,11 +87,12 @@ def _read_save_metadata(self, slot_path): ).strftime("%Y-%m-%d %H:%M:%S") return metadata - except Exception: + except (json.JSONDecodeError, IOError, OSError) as e: + # Return None for corrupted or inaccessible save files return None def get_next_available_slot(self): - """Returns the next available save slot number""" + """Returns the next available save slot number, or None if all slots are full""" save_files = self.list_save_files() if not save_files: return 1 @@ -81,7 +102,8 @@ def get_next_available_slot(self): for i in range(1, 100): if i not in existing_slots: return i - return len(existing_slots) + 1 + # All 99 slots are full + return None def select_save_slot(self, slot_number): """Select a save slot to use""" @@ -97,7 +119,7 @@ def get_save_path(self, filename): # Create slot directory if it doesn't exist if not os.path.exists(slot_path): - os.makedirs(slot_path) + os.makedirs(slot_path, exist_ok=True) return os.path.join(slot_path, filename) @@ -110,3 +132,30 @@ def delete_save_slot(self, slot_number): shutil.rmtree(slot_path) return True return False + + def migrate_old_save_files(self): + """Migrate old save files (data/*.json) to slot_1 if they exist""" + old_player = os.path.join(self.data_directory, "player.json") + old_stats = os.path.join(self.data_directory, "stats.json") + old_time = os.path.join(self.data_directory, "timeService.json") + + # Check if old save files exist + if not os.path.exists(old_player): + return False + + # Create slot_1 directory + slot_1_path = os.path.join(self.data_directory, "slot_1") + if not os.path.exists(slot_1_path): + os.makedirs(slot_1_path, exist_ok=True) + + # Move files to slot_1 + try: + if os.path.exists(old_player): + shutil.move(old_player, os.path.join(slot_1_path, "player.json")) + if os.path.exists(old_stats): + shutil.move(old_stats, os.path.join(slot_1_path, "stats.json")) + if os.path.exists(old_time): + shutil.move(old_time, os.path.join(slot_1_path, "timeService.json")) + return True + except (IOError, OSError): + return False diff --git a/tests/test_saveFileManager.py b/tests/test_saveFileManager.py index 5b048ba..75c6f03 100644 --- a/tests/test_saveFileManager.py +++ b/tests/test_saveFileManager.py @@ -225,3 +225,62 @@ def test_read_save_metadata_corrupted_json(): assert metadata is None finally: shutil.rmtree(temp_dir) + + +def test_migrate_old_save_files(): + temp_dir = tempfile.mkdtemp() + try: + manager = SaveFileManager(temp_dir) + + # Create old format save files + os.makedirs(temp_dir, exist_ok=True) + with open(os.path.join(temp_dir, "player.json"), "w") as f: + json.dump({"money": 100, "fishCount": 5}, f) + with open(os.path.join(temp_dir, "stats.json"), "w") as f: + json.dump({"totalFishCaught": 10}, f) + with open(os.path.join(temp_dir, "timeService.json"), "w") as f: + json.dump({"day": 2}, f) + + # Migrate + result = manager.migrate_old_save_files() + assert result is True + + # Check that files were moved to slot_1 + assert os.path.exists(os.path.join(temp_dir, "slot_1", "player.json")) + assert os.path.exists(os.path.join(temp_dir, "slot_1", "stats.json")) + assert os.path.exists(os.path.join(temp_dir, "slot_1", "timeService.json")) + + # Check that old files are gone + assert not os.path.exists(os.path.join(temp_dir, "player.json")) + assert not os.path.exists(os.path.join(temp_dir, "stats.json")) + assert not os.path.exists(os.path.join(temp_dir, "timeService.json")) + finally: + shutil.rmtree(temp_dir) + + +def test_migrate_old_save_files_no_old_saves(): + temp_dir = tempfile.mkdtemp() + try: + manager = SaveFileManager(temp_dir) + result = manager.migrate_old_save_files() + assert result is False + finally: + shutil.rmtree(temp_dir) + + +def test_get_next_available_slot_all_full(): + temp_dir = tempfile.mkdtemp() + try: + manager = SaveFileManager(temp_dir) + + # Create 99 save slots + for i in range(1, 100): + slot_path = os.path.join(temp_dir, f"slot_{i}") + os.makedirs(slot_path) + with open(os.path.join(slot_path, "player.json"), "w") as f: + json.dump({"money": 0}, f) + + next_slot = manager.get_next_available_slot() + assert next_slot is None + finally: + shutil.rmtree(temp_dir)