Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/fishE.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,15 @@ def __init__(self, interfaceType=INTERFACE_TYPE):
if os.path.exists(time_path) and os.path.getsize(time_path) > 0:
self.loadTimeService()

# loadPlayer()/loadStats() rebind self.player/self.stats to brand-new
# objects, but only loadTimeService() rebuilds the TimeService around
# them. When a slot has player.json/stats.json but no timeService.json,
# 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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

self.timeService.stats = self.stats

# Point the UI at the (possibly reloaded) game state.
self.userInterface.player = self.player
self.userInterface.timeService = self.timeService
Expand Down
85 changes: 85 additions & 0 deletions tests/test_fishE.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,91 @@ def createGameForPersistence(data_directory):
return game


def createGameThroughInit(data_directory, saveFiles):
# Run the real FishE.__init__ against a temp save slot holding exactly
# 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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

fishE.Stats = Stats
fishE.TimeService = TimeService
fishE.Prompt = Prompt
fishE.PlayerJsonReaderWriter = PlayerJsonReaderWriter
fishE.StatsJsonReaderWriter = StatsJsonReaderWriter
fishE.TimeServiceJsonReaderWriter = TimeServiceJsonReaderWriter
fishE.SaveFileManager = SaveFileManager

slot = os.path.join(data_directory, "slot_1")
os.makedirs(slot, exist_ok=True)
for filename, contents in saveFiles.items():
with open(os.path.join(slot, filename), "w") as f:
json.dump(contents, f)

config = Config()
config.dataDirectory = data_directory

def selectSlotOne(self):
self.saveFileManager.select_save_slot(1)

with patch.object(fishE, "Config", return_value=config), patch.object(
fishE, "UserInterfaceFactory", MagicMock()
), patch.object(fishE.FishE, "_selectSaveFile", selectSlotOne):
return fishE.FishE()


def test_init_rebinds_timeService_to_loaded_player_without_timeService_file():
with tempfile.TemporaryDirectory() as data_directory:
# prepare/call - a slot holding player.json and stats.json but no
# timeService.json, which is the shape migrate_old_save_files() produces
# from an old save that never had one
game = createGameThroughInit(
data_directory,
{
"player.json": PlayerJsonReaderWriter().createJsonFromPlayer(Player()),
"stats.json": StatsJsonReaderWriter().createJsonFromStats(Stats()),
},
)

# check - the TimeService drives the same objects the rest of the game
# uses, so daily interest/income/rent land on the loaded player
assert game.timeService.player is game.player
assert game.timeService.stats is game.stats


def test_init_rebinds_timeService_when_only_player_file_present():
with tempfile.TemporaryDirectory() as data_directory:
# prepare/call - the shape left behind by a save interrupted after
# player.json was written but before stats.json/timeService.json
game = createGameThroughInit(
data_directory,
{"player.json": PlayerJsonReaderWriter().createJsonFromPlayer(Player())},
)

# check
assert game.timeService.player is game.player
assert game.timeService.stats is game.stats


def test_init_daily_tick_credits_the_loaded_player():
with tempfile.TemporaryDirectory() as data_directory:
# prepare - a saved player with money in the bank, in a slot with no
# timeService.json
savedPlayer = Player()
savedPlayer.moneyInBank = 100
game = createGameThroughInit(
data_directory,
{"player.json": PlayerJsonReaderWriter().createJsonFromPlayer(savedPlayer)},
)
moneyInBankBefore = game.player.moneyInBank

# call
game.timeService.increaseDay()

# check - bank interest reaches the player the game actually reads
assert game.player.moneyInBank > moneyInBankBefore
assert game.stats.moneyMadeFromInterest > 0


def test_save_then_load_roundtrip():
# restore real classes in case an earlier test mocked the module globals
fishE.Player = Player
Expand Down
Loading