Add multiple save file support with save slot management#52
Add multiple save file support with save slot management#52
Conversation
Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com>
Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com>
Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com>
Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR adds multiple save file support to the FishE game, allowing players to maintain independent save files in separate slots. The implementation introduces a SaveFileManager class to handle slot operations and updates the FishE initialization to display an interactive save selection menu at startup.
Changes:
- New SaveFileManager class manages save slots with metadata display, creation, deletion, and gap-filling
- FishE initialization updated to show save selection menu and route all save/load operations through selected slot paths
- Added 15 comprehensive tests for SaveFileManager functionality and updated existing FishE test to mock save selection
Reviewed changes
Copilot reviewed 5 out of 6 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| src/saveFileManager.py | New class managing save slots in slot_N directories with metadata reading and slot operations |
| src/fishE.py | Added save selection menu, integrated SaveFileManager for all save/load operations, added file close calls |
| tests/test_saveFileManager.py | Comprehensive test suite covering slot management, metadata reading, edge cases, and error conditions |
| tests/test_fishE.py | Updated to mock SaveFileManager and _selectSaveFile method to avoid stdin interaction |
| README.md | Documented the new multiple save files feature with usage details |
| .gitignore | Changed from data/*.json to data/ to ignore entire data directory including slot subdirectories |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
src/fishE.py
Outdated
| 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() |
There was a problem hiding this comment.
File operations in save(), loadPlayer(), loadStats(), and loadTimeService() methods lack error handling. If any file operation fails (e.g., disk full, permission denied, corrupted file), the game will crash without a helpful error message. Consider adding try-except blocks to catch and handle IOError or OSError exceptions gracefully, possibly notifying the user of the issue.
There was a problem hiding this comment.
Added comprehensive error handling with try-except blocks for IOError, OSError, and json.JSONDecodeError in all file operations. The game now gracefully handles disk full, permission denied, and corrupted file scenarios with user-friendly warnings. (6c58c97)
src/fishE.py
Outdated
| 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() |
There was a problem hiding this comment.
While adding .close() calls is good, consider using context managers (with statements) for file operations to ensure files are closed even if an exception occurs. This pattern is already used in SaveFileManager (lines 51, 59) and would make the code more robust and consistent. For example: with open(self.saveFileManager.get_save_path("player.json"), "w") as playerSaveFile:
There was a problem hiding this comment.
Updated all file operations to use context managers (with statements). This ensures files are properly closed even if exceptions occur, making the code more robust and consistent with SaveFileManager's existing patterns. (6c58c97)
src/saveFileManager.py
Outdated
| 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 |
There was a problem hiding this comment.
If all 99 save slots are filled, get_next_available_slot() returns 100 (line 84), but list_save_files() only iterates through range(1, 100) which stops at 99 (line 22). This means slot 100 would be created but never listed. Consider either: 1) returning None when all slots are full and handling this in the UI, or 2) extending the range to match in both functions, or 3) adding a check to prevent creating slot 100.
There was a problem hiding this comment.
Fixed by returning None when all 99 slots are full instead of 100. The UI now checks for None and displays "All save slots are full. Please delete a save first." Added test coverage for this edge case. (6c58c97)
| # Show save file selection menu | ||
| self._selectSaveFile() |
There was a problem hiding this comment.
This change breaks backward compatibility with existing save files. Users with saves in the old format (data/player.json, data/stats.json, data/timeService.json) will lose access to them since the code now only looks for slot_N directories. Consider adding migration logic to automatically move existing saves to slot_1 on first run, or at minimum document this breaking change clearly in release notes so users can manually migrate their saves before updating.
There was a problem hiding this comment.
Added automatic migration! The migrate_old_save_files() method now runs on startup and automatically moves existing saves from the old format (data/*.json) to slot_1 in the new format. Tested with actual old save files - migration completes silently and users can continue their game without data loss. (6c58c97)
src/fishE.py
Outdated
| if not os.path.exists("data"): | ||
| os.makedirs("data") |
There was a problem hiding this comment.
The hardcoded "data" directory check is inconsistent with the SaveFileManager's configurable data_directory parameter. This creates a directory that might not match the SaveFileManager's configuration, potentially causing issues if a custom data directory is used. Consider using self.saveFileManager.data_directory instead of the hardcoded "data" string, or remove this check entirely since get_save_path() already creates the necessary directories (lines 99-100 in saveFileManager.py).
There was a problem hiding this comment.
Fixed! Changed hardcoded "data" to use self.saveFileManager.data_directory for consistency. Since get_save_path() already creates necessary directories, the redundant check is now properly aligned with SaveFileManager's configuration. (6c58c97)
src/fishE.py
Outdated
| self._deleteSaveFile(save_files) | ||
| # Recursively call to show updated menu | ||
| self._selectSaveFile() | ||
| return |
There was a problem hiding this comment.
The recursive call to _selectSaveFile() after deleting a save file could lead to a stack overflow if a user repeatedly deletes and cancels many times. Consider using a loop-based approach instead of recursion to handle menu refreshes, or at least add a depth limit.
There was a problem hiding this comment.
Replaced recursion with a loop-based approach. The _selectSaveFile() method now uses while True at the top level, and _deleteSaveFile() returns True/False to control flow. This eliminates any risk of stack overflow regardless of user behavior. (6c58c97)
src/saveFileManager.py
Outdated
| except Exception: | ||
| return None |
There was a problem hiding this comment.
The bare except clause catches all exceptions silently, making debugging difficult. While returning None is appropriate for corrupted save files, consider logging the exception or being more specific about which exceptions to catch (e.g., json.JSONDecodeError, IOError). This would help identify issues during development and provide better diagnostics.
There was a problem hiding this comment.
Changed from bare except to catch specific exceptions: (json.JSONDecodeError, IOError, OSError). This makes debugging easier while still returning None for corrupted/inaccessible save files as intended. (6c58c97)
src/saveFileManager.py
Outdated
| # 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, | ||
| } | ||
| ) |
There was a problem hiding this comment.
The loop iterating through range(1, 100) checks for the existence of 99 directories on every call to list_save_files(). This could be inefficient if called frequently. Consider using os.listdir() to only check directories that actually exist, filtering for those matching the "slot_N" pattern. This would scale better with the number of actual save files rather than the maximum possible slots.
| # 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, | |
| } | |
| ) | |
| # Look for save slots (slot_1, slot_2, etc.) by inspecting existing directories | |
| 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, | |
| } | |
| ) |
There was a problem hiding this comment.
Optimized using os.listdir() as suggested! Now only checks directories that actually exist and filters by the "slot_N" pattern. This scales with actual save files (typically 1-5) rather than always checking 99 slots. Much more efficient. (6c58c97)
src/saveFileManager.py
Outdated
|
|
||
| # Create slot directory if it doesn't exist | ||
| if not os.path.exists(slot_path): | ||
| os.makedirs(slot_path) |
There was a problem hiding this comment.
Consider adding exist_ok=True parameter to os.makedirs() for robustness. While unlikely in single-player context, this prevents potential FileExistsError if the directory is created between the existence check and the makedirs call. This is a defensive programming practice that makes the code more resilient.
| os.makedirs(slot_path) | |
| os.makedirs(slot_path, exist_ok=True) |
There was a problem hiding this comment.
Added exist_ok=True to os.makedirs() for defensive programming. While unlikely in single-player context, this prevents potential FileExistsError race conditions. (6c58c97)
|
@copilot address comments |
…dd migration Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com>
Multiple Save Files Implementation - COMPLETE ✅
Security Summary
✅ No security vulnerabilities detected by CodeQL analysis.
Original prompt
💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.