Rebind TimeService to the loaded player and stats after save load - #123
Conversation
loadPlayer()/loadStats() replace self.player/self.stats with new objects, but only loadTimeService() rebuilds the TimeService around them. A slot holding player.json without timeService.json therefore left the TimeService pointing at the discarded new-game defaults, so every daily tick - bank interest, crew catch, investment income, rent - applied to a player nobody reads. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dmccoystephenson
left a comment
There was a problem hiding this comment.
Self-review: the fix is correct and minimally scoped. Verified loadPlayer/loadStats/loadTimeService are called only from __init__ (src/fishE.py:57, :61, :65 — no other call sites), so a single rebind after the load block covers every path that can replace self.player/self.stats, including the except fallbacks inside the loaders. It runs unconditionally, so the already-correct case (timeService.json present) re-assigns the same objects and is a no-op. Placement is before the self.locations dict is built, so every location gets the same TimeService instance it always did. Schemas and reader-writers re-checked field-for-field and are untouched by this change; no front-end path is involved. Two notes below, neither blocking.
| # the TimeService built from the defaults above would keep pointing at | ||
| # the discarded objects, so every daily tick (interest, crew catch, | ||
| # investment income, rent) would apply to a player nobody reads. | ||
| self.timeService.player = self.player |
There was a problem hiding this comment.
Rebinding attributes here keeps the fix small, but it does leave two places that must agree about what the current player is (this block and the TimeService.__init__ that captured them). A more structural fix would be to defer constructing TimeService until after the load block entirely — it is only needed earlier because UserInterfaceFactory.create_user_interface takes it at line 44 so the save-file menu can render. Not worth the churn in this PR, and the comment above documents the coupling, but worth knowing if the load sequence is reworked later.
| # saveFiles ({filename: json-serializable}), with only the save-slot menu | ||
| # and the front-end stubbed out - everything else is the real wiring, so | ||
| # the load block and the state it hands to TimeService are exercised. | ||
| fishE.Player = Player |
There was a problem hiding this comment.
This helper reassigns fishE's module globals without restoring them, which is the same thing createFishE (line 22) and the existing persistence tests already do in this file, so it is consistent rather than new. It is also order-independent in both directions: createFishE re-mocks every global it cares about, and this helper re-binds every real class it needs, so neither can be poisoned by the other regardless of which runs first. Confirmed by running the full suite (371 passed).
Summary
FishE.__init__buildsTimeService(self.player, self.stats)from the new-game defaults, then loads each save file only if it exists and is non-empty.loadPlayer()/loadStats()rebindself.player/self.statsto brand-new objects, but onlyloadTimeService()rebuilds theTimeServicearound them — so a slot holdingplayer.jsonbut notimeService.jsonleftself.timeService.player/.statspointing at the discarded defaults.TimeService.increaseDaycredits bank interest toself.player, then handsself.player, self.statstobusiness.runDailyProduction,investments.runDailyIncomeandhousing.runDailyRent. With a stale reference, a day rollover silently credited interest, crew catch and rental income to an object nobody reads, and charged rent against it too.self.timeService.player/.statsonce after the load block, alongside the existingself.userInterface.player/.timeServicerepointing.That slot shape is reachable in practice:
SaveFileManager.migrate_old_save_filesgates onplayer.jsononly and moves whichever of the three files exist; the same shape also results from a save interrupted partway throughFishE.save()(which writesplayer.jsonfirst), or from a zero-bytetimeService.jsonthat thegetsize(...) > 0guard skips.Test plan
python3 -m compileall -q src testspython3 -m pytest --cov=src --cov-report=term-missing --cov-report=xml:cov.xml— 371 passed (368 before), coverage 94%main(stashing only thesrc/fishE.pychange) and pass with itFishE.__init__, below theBaseUserInterfacecontract, so console/pygame/web are all unaffectedPlayer/Stats/TimeServicefield added, renamed or retyped, soschemas/*.jsonand the*JsonReaderWriterclasses are unchanged (re-verified all three still match their reader-writer field-for-field)New tests in
tests/test_fishE.py, driving the realFishE.__init__against a temp save slot with only the save-slot menu and front-end stubbed:test_init_rebinds_timeService_to_loaded_player_without_timeService_file— slot withplayer.json+stats.json, notimeService.jsontest_init_rebinds_timeService_when_only_player_file_present— interrupted-save shapetest_init_daily_tick_credits_the_loaded_player— behavioural:increaseDay()reaches the player the game actually readsCloses #120
This PR description was drafted during a Gardener session (Stephenson-Software/gardener).