Summary
During a triage pass, a zero-byte player.json was found to skip the load machinery entirely rather than failing it. The slot is still offered as loadable, the player is silently handed a starting character, and the empty file is overwritten on the first action.
The guard is a size check, not a load attempt (src/fishE.py:65):
player_path = self.saveFileManager.get_save_path("player.json")
if os.path.exists(player_path) and os.path.getsize(player_path) > 0:
self.loadPlayer()
An empty file passes os.path.exists and fails getsize(...) > 0, so loadPlayer() is never called, nothing is appended to failedLoads, and neither the dialogue nor the damaged-... copy added by #169 fires. stats.json and timeService.json are non-empty and load normally, so the run continues on the saved calendar and the saved lifetime stats with a brand-new player underneath them.
SaveFileManager._read_save_metadata has the same size guard at src/saveFileManager.py:72, so it returns a metadata dict carrying day, time and last_modified but no money, fishCount or energy. That dict is truthy, so the slot is listed.
Reproduced against the real SaveFileManager and the real FishE.__init__, with a slot holding an empty player.json beside an intact stats.json (900 fish caught, $8,000 earned) and timeService.json (day 40):
list_save_files() -> [{'slot': 1, ..., 'metadata': {'day': 40, 'time': 9, 'last_modified': ...}}]
failedLoads -> []
showDialogue called -> 0
damaged backups -> []
player.money -> 20 player.fishCount -> 0
stats.totalFishCaught -> 900
timeService.day -> 40
player.json after one save -> {"fishCount": 0, ..., "money": 20, ..., "boats": [], "homeTier": 0, ...}
The menu therefore renders Load Slot 1 (Day 40, $0, 0 fish) — because _selectSaveFile falls back to metadata.get("money", 0) and metadata.get("fishCount", 0) (src/fishE.py:159-167) — and picking it starts a day-40 character with $20, no boat and no home.
Why it matters
The state the player is left in is incoherent rather than merely reset. It is day 40 with 900 lifetime fish caught and $8,000 lifetime earnings on the stats screen, held by someone with $20, no boat and nowhere to sleep. progression.catchUp then runs over the loaded stats, so features earned on lifetime numbers stay open around that character — unlockedFeatures comes out as ['shop', 'home', 'housing', 'tavern'] — while everything gated on what the player actually owns is gone. It reads as a save that has been robbed rather than one that failed.
Nothing is said at any point, and the empty file is replaced by the fresh player on the first save(), so the only evidence that a run existed is a stats.json nobody is prompted to look at.
This is distinct from the two issues nearby:
An empty file is a likely artefact of the old truncating write, which #169 replaced — so existing damaged slots on disk can still be in this state even though new ones will not be created this way.
Suggested fix
Treat an empty save file as a failed load rather than as an absent one. Dropping the getsize(...) > 0 half of each of the three guards at src/fishE.py:65, :69 and :73 would send an empty file into loadPlayer(), where json.load raises JSONDecodeError, which is already caught — so it would be recorded in failedLoads, reported through the front-end, and the slot copied aside, with no new machinery needed.
Worth checking alongside: whether a slot whose player.json carries no usable metadata should be listed as loadable at all (src/saveFileManager.py:72), which overlaps with #150's suggested marker.
Filed by Claude during a Gardener session (https://github.com/Stephenson-Software/gardener); claims above were verified by running the code shown.
Summary
During a triage pass, a zero-byte
player.jsonwas found to skip the load machinery entirely rather than failing it. The slot is still offered as loadable, the player is silently handed a starting character, and the empty file is overwritten on the first action.The guard is a size check, not a load attempt (
src/fishE.py:65):An empty file passes
os.path.existsand failsgetsize(...) > 0, soloadPlayer()is never called, nothing is appended tofailedLoads, and neither the dialogue nor thedamaged-...copy added by #169 fires.stats.jsonandtimeService.jsonare non-empty and load normally, so the run continues on the saved calendar and the saved lifetime stats with a brand-new player underneath them.SaveFileManager._read_save_metadatahas the same size guard atsrc/saveFileManager.py:72, so it returns a metadata dict carryingday,timeandlast_modifiedbut nomoney,fishCountorenergy. That dict is truthy, so the slot is listed.Reproduced against the real
SaveFileManagerand the realFishE.__init__, with a slot holding an emptyplayer.jsonbeside an intactstats.json(900 fish caught, $8,000 earned) andtimeService.json(day 40):The menu therefore renders
Load Slot 1 (Day 40, $0, 0 fish)— because_selectSaveFilefalls back tometadata.get("money", 0)andmetadata.get("fishCount", 0)(src/fishE.py:159-167) — and picking it starts a day-40 character with $20, no boat and no home.Why it matters
The state the player is left in is incoherent rather than merely reset. It is day 40 with 900 lifetime fish caught and $8,000 lifetime earnings on the stats screen, held by someone with $20, no boat and nowhere to sleep.
progression.catchUpthen runs over the loaded stats, so features earned on lifetime numbers stay open around that character —unlockedFeaturescomes out as['shop', 'home', 'housing', 'tavern']— while everything gated on what the player actually owns is gone. It reads as a save that has been robbed rather than one that failed.Nothing is said at any point, and the empty file is replaced by the fresh player on the first
save(), so the only evidence that a run existed is astats.jsonnobody is prompted to look at.This is distinct from the two issues nearby:
player.json, which makes the slot disappear from the menu. Here the slot is listed and picked.damaged-...copy Stop a damaged save from being destroyed, and say so on screen #169 added are both bypassed.An empty file is a likely artefact of the old truncating write, which #169 replaced — so existing damaged slots on disk can still be in this state even though new ones will not be created this way.
Suggested fix
Treat an empty save file as a failed load rather than as an absent one. Dropping the
getsize(...) > 0half of each of the three guards atsrc/fishE.py:65,:69and:73would send an empty file intoloadPlayer(), wherejson.loadraisesJSONDecodeError, which is already caught — so it would be recorded infailedLoads, reported through the front-end, and the slot copied aside, with no new machinery needed.Worth checking alongside: whether a slot whose
player.jsoncarries no usable metadata should be listed as loadable at all (src/saveFileManager.py:72), which overlaps with #150's suggested marker.Filed by Claude during a Gardener session (https://github.com/Stephenson-Software/gardener); claims above were verified by running the code shown.