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 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. diff --git a/src/fishE.py b/src/fishE.py index bae3b3b..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 @@ -9,6 +10,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 +21,31 @@ def __init__(self): self.playerJsonReaderWriter = PlayerJsonReaderWriter() self.timeServiceJsonReaderWriter = TimeServiceJsonReaderWriter() 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() # 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 +94,102 @@ def __init__(self): self.currentLocation = LocationType.HOME + def _selectSaveFile(self): + """Display save file selection menu and let user choose""" + 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) + + choice = input("\n Select an option: ").strip().upper() + + if choice == "Q": + print("\n Goodbye!") + exit(0) + 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}...") + 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}...") + return + else: + print(" Invalid slot number. Try again.") + else: + print(" Invalid choice. Try again.") + + def _deleteSaveFile(self, save_files): + """Delete a save file. Returns True if a file was deleted, False if cancelled.""" + 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 False + 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 True + else: + print(f"\n Failed to delete Slot {slot_num}.") + return False + else: + return False + else: + print(" Invalid slot number. Try again.") + else: + print(" Invalid choice. Try again.") + def play(self): while self.running: # change location @@ -103,37 +205,53 @@ def play(self): self.save() def save(self): - # create data directory - if not os.path.exists("data"): - os.makedirs("data") + # 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) - playerSaveFile = open("data/player.json", "w") - self.playerJsonReaderWriter.writePlayerToFile(self.player, playerSaveFile) + try: + with open(self.saveFileManager.get_save_path("player.json"), "w") as playerSaveFile: + self.playerJsonReaderWriter.writePlayerToFile(self.player, playerSaveFile) - timeServiceSaveFile = open("data/timeService.json", "w") - self.timeServiceJsonReaderWriter.writeTimeServiceToFile( - self.timeService, timeServiceSaveFile - ) + with open(self.saveFileManager.get_save_path("timeService.json"), "w") as timeServiceSaveFile: + self.timeServiceJsonReaderWriter.writeTimeServiceToFile( + self.timeService, timeServiceSaveFile + ) - statsSaveFile = open("data/stats.json", "w") - self.statsJsonReaderWriter.writeStatsToFile(self.stats, statsSaveFile) + 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("data/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("data/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("data/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 new file mode 100644 index 0000000..07f727c --- /dev/null +++ b/src/saveFileManager.py @@ -0,0 +1,161 @@ +import os +import json +import shutil +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.) 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": 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): + """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 (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, or None if all slots are full""" + 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 + # All 99 slots are full + return None + + 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, exist_ok=True) + + 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): + 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_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..75c6f03 --- /dev/null +++ b/tests/test_saveFileManager.py @@ -0,0 +1,286 @@ +import os +import json +import tempfile +import shutil +import pytest +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() + with pytest.raises(ValueError, match="No save slot selected"): + manager.get_save_path("player.json") + + +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) + + +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)