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..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..c93b1c9 --- /dev/null +++ b/src/saveFileManager.py @@ -0,0 +1,112 @@ +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.) + 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): + 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..5b048ba --- /dev/null +++ b/tests/test_saveFileManager.py @@ -0,0 +1,227 @@ +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)