Keep a damaged save slot visible, and stop it being overwritten - #171
Conversation
A slot whose player.json would not parse returned no metadata, which dropped it from list_save_files() and therefore from the slot numbers get_next_available_slot() treats as taken - so the slot vanished from the menu and was handed straight back as "Create New Save", pointing the next save at the occupied directory and destroying the intact stats.json and timeService.json beside the damaged file. An empty player.json failed differently: the "size > 0" half of each load guard skipped the read entirely, so nothing reached failedLoads and neither the warning nor the damaged- copy fired. The player was handed a starting character on the saved calendar with nothing said. - _read_save_metadata now returns an "unreadable" marker instead of None, so the slot stays listed and stays claimed - player.json is read strictly (no size guard, and a non-object is unreadable rather than an AttributeError that would take the menu down); timeService.json is read tolerantly, since a damaged calendar does not make the run unloadable - the save menu shows the slot as "Slot N - damaged, cannot be loaded", unpickable with a reason, and tags it "(damaged)" in the delete menu - deleting is how the slot is reclaimed - the three load guards test existence only, so an empty file reaches the loader and is reported and copied aside like any other damage Closes #150 Closes #170 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The label carried "damaged, cannot be loaded" and the reason carried
"this save is damaged", so a front-end that appends the reason to the row
rendered a stutter:
[1] Slot 1 - damaged, cannot be loaded (unavailable: this save is
damaged - delete it to reuse the slot)
The label now only identifies the slot and the blocker lives in the
reason, which is how every other unusable option in the game is built:
[1] Slot 1 (damaged) (unavailable: can't be read - delete it to reuse
the slot)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dmccoystephenson
left a comment
There was a problem hiding this comment.
Self-review: read the full diff against the two issues and the surrounding save/load machinery. No correctness problems found; the notes below record the three judgement calls a later reader is most likely to want to undo, plus one pre-existing observation worth a follow-up issue rather than a change here.
One finding from this pass was already fixed in d8b0b12 rather than left as a comment: the damaged row put the blocker in both the label and the reason, so a front-end that appends the reason rendered Slot 1 - damaged, cannot be loaded (unavailable: this save is damaged - delete it to reuse the slot). The label now only identifies the slot, matching how every other unusable option in the game is built.
Deliberately not changed: the _read_save_metadata marker is not derived from schema validation. Keeping the metadata read cheap and limited to "does this parse" leaves a schema-invalid save loadable, which is correct — #169 already reports and preserves that case on load, and a save whose values are out of range is far more recoverable than one that is not JSON. Coupling SaveFileManager to the reader/writers and schemas/ to catch it in the menu would be a worse trade.
| if isinstance(time_data, dict): | ||
| metadata["day"] = time_data.get("day", 1) | ||
| metadata["time"] = time_data.get("time", 0) | ||
| except (json.JSONDecodeError, IOError, OSError): |
There was a problem hiding this comment.
Deliberate asymmetry, flagged so it doesn't read as a copy-paste slip against the strict player.json read 20 lines above: this one swallows the error and keeps the slot loadable.
timeService.json is not the run. A slot whose calendar is damaged still holds a readable player, and FishE.loadTimeService already records the failure in failedLoads, reports it through the front-end and copies the slot aside. Marking the slot unreadable here would take a mostly-intact run and lock it out of the menu entirely — strictly worse for the player than losing the day counter.
The cost is that day/time are simply absent, which _selectSaveFile renders with its own Day 1 default, so the label can understate a damaged-calendar slot. That seemed better than refusing to load it.
| except (json.JSONDecodeError, IOError, OSError) as error: | ||
| return self._unreadable_save_metadata(player_file, error) | ||
|
|
||
| if not isinstance(player_data, dict): |
There was a problem hiding this comment.
This guard is not defensive padding — it closes a real crash that the original except clause could not catch.
json.load on a player.json holding valid JSON that isn't an object (42, null, "x", or some unrelated file copied over the save) returns a non-dict, and the player_data.get(...) calls below raise AttributeError. That is not in (json.JSONDecodeError, IOError, OSError), so it would propagate out of _read_save_metadata and out of list_save_files — taking the entire save menu down on startup over one bad slot, which is a strictly worse failure than the disappearing slot #150 is about.
Covered by test_read_save_metadata_player_file_that_is_not_an_object, which asserts the listing still works rather than only checking the return value.
| # loop to show the refreshed menu either way | ||
| elif kind == "quit": | ||
| exit(0) | ||
| elif kind == "damaged": |
There was a problem hiding this comment.
This branch should be unreachable and is here on purpose; worth justifying rather than leaving as apparently-dead code.
showOptions is contracted not to return an unavailable option's number, and unavailableReasons' all-unavailable fallback cannot fire here because Quit is always selectable. But without a branch, kind == "damaged" falls out of the if/elif chain and the while True simply 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 by test_selectSaveFile_explains_a_damaged_slot_a_front_end_let_through.
|
|
||
| metadata["money"] = player_data.get("money", 0) | ||
| metadata["fishCount"] = player_data.get("fishCount", 0) | ||
| metadata["energy"] = player_data.get("energy", 100) |
There was a problem hiding this comment.
Observation, pre-existing and not changed by this PR (the line moved but the behaviour is untouched): metadata["energy"] has no production consumer. _selectSaveFile reads only day, money and fishCount, and the only other reference in the repo is an assertion in test_read_save_metadata_partial_data.
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.
Summary
Two ways a damaged save could cost the player their run, both of which ended with the damaged slot being overwritten rather than reported.
A
player.jsonthat would not parse (#150) made_read_save_metadatareturnNone. That dropped the slot fromlist_save_files(), andget_next_available_slot()derives the taken slot numbers from that same filtered list — so the slot vanished from the menu and was handed straight back asCreate New Save. Picking the only option offered pointedselect_save_slotat the occupied directory, and the nextsave()destroyed the intactstats.jsonandtimeService.jsonsitting beside the damaged file.An empty
player.json(#170) failed differently: theos.path.getsize(...) > 0half of each load guard skipped the read entirely, soloadPlayer()never ran, nothing reachedfailedLoads, and neither the warning dialogue nor thedamaged-...copy added by #169 fired. The player was handed a starting character on the saved calendar and the empty file was overwritten on the first action.What changed
_read_save_metadatareturns an{"unreadable": True, ...}marker instead ofNone, so a damaged slot stays listed and stays claimed. Those two halves have to move together: visibility alone would not stop the overwrite, and counting it alone would not tell the player it exists.player.jsonis now read strictly — no size guard, since an empty file is damaged rather than absent. Valid JSON that is not an object is also treated as unreadable, becauseplayer_data.get(...)would raiseAttributeError, which is not among the caught errors and would take the whole save menu down over one bad slot.timeService.jsonis read tolerantly. A damaged calendar is not a damaged run: the player still loads, andFishEalready reports and preserves that damage on load, so the slot stays loadable and the label just falls back to itsDay 1default.Slot N - damaged, cannot be loadedand marks it unpickable with a reason, reusing theunavailableOptionsmechanism from Grey out menu options the game would refuse, with the reason #168. Deleting is how the slot is reclaimed, so the reason says so and the delete menu tags that row(damaged).JSONDecodeError, and is reported and copied aside by the machinery Stop a damaged save from being destroyed, and say so on screen #169 already added — no new reporting path.The two mechanisms stay complementary rather than redundant: the menu catches what it can see cheaply (unparseable
player.json), while the load path still covers damage the metadata read cannot detect — a save that parses but fails schema validation, or a badstats.json/timeService.json.Verification
Reproduced both issues' own repro scenarios against the fixed code. Issue #150's slot (truncated
player.jsonbeside intactstats.json/timeService.json):Issue #170's slot (empty
player.json):Test plan
python3 -m compileall -q src testsSDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy python3 -m pytest --verbose -vv --cov=src --cov-report=term-missing --cov-report=xml:cov.xml— 791 passed, 97% total coverage; every new branch coveredtest_read_save_metadata_corrupted_json,test_read_save_metadata_empty_player_file) were updated to the marker — that behaviour was the bugshowOptions(descriptor, options, unavailable)call site, and all three implementations (UserInterface,PygameUserInterface,WebUserInterface— withConsoleUserInterface/PyodideUserInterfaceinheriting) already honourunavailableOptionsfrom Grey out menu options the game would refuse, with the reason #168;web/client.jsalready greys the row and shows the reason. No front-end-specific code was needed or added.Player/Stats/TimeServicefield was added, renamed or retyped, so noschemas/*.jsonchange applies; cross-checked all three schemas against their*JsonReaderWriteranyway and they are in sync.README.md's save-file section updated to match (it described the pre-A save whose player.json is corrupt disappears from the menu and its slot is offered as "Create New Save" #150 menu);PLANNING.mdneeded no change.black/autoflakerun performat.sh. Note: this sandbox'sblackis 23.1.0 and reformats four files this PR does not touch (src/business/export.py,src/location/shop.py,src/npc/villagers.py,tests/ui/test_consoleUserInterface.py) — that churn was reverted and left out. The five files here areblack --checkclean.Closes #150
Closes #170
drafted by Claude on behalf of Daniel Stephenson