-
Notifications
You must be signed in to change notification settings - Fork 0
Keep a damaged save slot visible, and stop it being overwritten #171
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -58,41 +58,87 @@ def list_save_files(self): | |
| return save_files | ||
|
|
||
| def _read_save_metadata(self, slot_path): | ||
| """Read metadata from a save slot""" | ||
| """Read metadata from a save slot. | ||
|
|
||
| Returns None only when the slot holds no run at all (no player.json). | ||
| A player.json that will not parse comes back as the marker described in | ||
| _unreadable_save_metadata rather than as None, so a damaged slot stays | ||
| listed and stays claimed instead of disappearing.""" | ||
| 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 = {} | ||
|
|
||
| # player.json is the file that holds the run, so it is read strictly. | ||
| # Deliberately no "size > 0" guard: an empty file is a damaged file | ||
| # rather than an absent one, and skipping the read for it is what let a | ||
| # zero-byte save be offered in the menu as a real one. | ||
| try: | ||
| with open(player_file, "r") as f: | ||
| player_data = json.load(f) | ||
| except (json.JSONDecodeError, IOError, OSError) as error: | ||
| return self._unreadable_save_metadata(player_file, error) | ||
|
|
||
| if not isinstance(player_data, dict): | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This guard is not defensive padding — it closes a real crash that the original
Covered by |
||
| # Valid JSON that is not an object - a bare number, or some other | ||
| # file copied over the save - has no fields to read, and .get() | ||
| # would raise AttributeError here, taking the whole menu down with | ||
| # it rather than reporting one bad slot. | ||
| return self._unreadable_save_metadata( | ||
| player_file, ValueError("not a JSON object") | ||
| ) | ||
|
|
||
| metadata["money"] = player_data.get("money", 0) | ||
| metadata["fishCount"] = player_data.get("fishCount", 0) | ||
| metadata["energy"] = player_data.get("energy", 100) | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Observation, pre-existing and not changed by this PR (the line moved but the behaviour is untouched): Left alone deliberately — removing it is unrelated to #150/#170 and would widen this diff. Worth a small follow-up issue to either drop it or put it on the slot label, where it would arguably be more useful than the fish count. |
||
|
|
||
| # The calendar is not the run: a slot whose timeService.json is missing | ||
| # or damaged still holds a loadable player, and FishE reports and | ||
| # preserves that damage on load. So this read is tolerant - leaving the | ||
| # fields out just falls the menu label back to its "Day 1" default. | ||
| 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 | ||
| with open(time_file, "r") as f: | ||
| time_data = json.load(f) | ||
| if isinstance(time_data, dict): | ||
| metadata["day"] = time_data.get("day", 1) | ||
| metadata["time"] = time_data.get("time", 0) | ||
| except (json.JSONDecodeError, IOError, OSError): | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Deliberate asymmetry, flagged so it doesn't read as a copy-paste slip against the strict
The cost is that |
||
| pass | ||
|
|
||
| metadata["last_modified"] = self._last_modified(player_file) | ||
|
|
||
| return metadata | ||
|
|
||
| def _last_modified(self, path): | ||
| """A file's modification time as a display string, or None if unknown.""" | ||
| try: | ||
| return datetime.fromtimestamp(os.path.getmtime(path)).strftime( | ||
| "%Y-%m-%d %H:%M:%S" | ||
| ) | ||
| except OSError: | ||
| return None | ||
|
|
||
| def _unreadable_save_metadata(self, player_file, error): | ||
| """Metadata standing in for a slot whose player.json will not parse. | ||
|
|
||
| Returned instead of None because list_save_files() drops a slot with no | ||
| metadata, and get_next_available_slot() derives the taken slot numbers | ||
| from that same filtered list - so a damaged slot used to vanish from the | ||
| menu *and* be handed straight back as "Create New Save", pointing the | ||
| next save at the occupied directory and overwriting the intact | ||
| stats.json and timeService.json sitting beside the damaged file. | ||
|
|
||
| Callers key off "unreadable" to show the slot as present but unpickable | ||
| (see FishE._selectSaveFile).""" | ||
| return { | ||
| "unreadable": True, | ||
| "reason": str(error), | ||
| "last_modified": self._last_modified(player_file), | ||
| } | ||
|
|
||
| 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() | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This branch should be unreachable and is here on purpose; worth justifying rather than leaving as apparently-dead code.
showOptionsis contracted not to return an unavailable option's number, andunavailableReasons' all-unavailable fallback cannot fire here becauseQuitis always selectable. But without a branch,kind == "damaged"falls out of theif/elifchain and thewhile Truesimply re-renders the same menu — the player picks the row, and the game appears to ignore them, with nothing said. That is an unexplained hang rather than a visible bug.I hit exactly this while writing the reproduction script for #150 (a mock returning
"1"unconditionally span the menu forever), which is what convinced me not to rely on the contract. A new front-end is the realistic way this gets violated for real, and front-end parity is the most common gap in this repo. Covered bytest_selectSaveFile_explains_a_damaged_slot_a_front_end_let_through.