diff --git a/src/fishE.py b/src/fishE.py index 0407a23..a88ea0c 100644 --- a/src/fishE.py +++ b/src/fishE.py @@ -1,5 +1,6 @@ import os import json +from jsonschema.exceptions import ValidationError from location import bank, docks, home, shop, tavern from location.enum.locationType import LocationType from player.player import Player @@ -15,6 +16,7 @@ from achievements import achievements from achievements.achievements import GOAL_AMOUNT, GOAL_MILESTONE_NAME from housing import housing +from config.config import Config # Which front-end the game runs. Swap to UIType.PYGAME (or a future web type) # here to change the interface — the rest of the game is front-end agnostic. @@ -26,15 +28,16 @@ class FishE: def __init__(self, interfaceType=INTERFACE_TYPE): self.running = True + self.config = Config() self.playerJsonReaderWriter = PlayerJsonReaderWriter() self.timeServiceJsonReaderWriter = TimeServiceJsonReaderWriter() self.statsJsonReaderWriter = StatsJsonReaderWriter() - self.saveFileManager = SaveFileManager() + self.saveFileManager = SaveFileManager(data_directory=self.config.dataDirectory) # Start from default (new-game) state, then build the UI so the save-file # manager can render and read input through the active front-end. A # chosen save is loaded over these defaults below. - self.player = Player() + self.player = Player(self.config) self.stats = Stats() self.timeService = TimeService(self.player, self.stats) self.prompt = Prompt("What would you like to do?") @@ -271,10 +274,10 @@ def loadPlayer(self): self.player = self.playerJsonReaderWriter.readPlayerFromFile( playerSaveFile ) - except (IOError, OSError, json.JSONDecodeError) as e: + except (IOError, OSError, json.JSONDecodeError, ValidationError) as e: print(f"\n Warning: Failed to load player data: {e}") print(" Creating new player...") - self.player = Player() + self.player = Player(self.config) def loadStats(self): try: @@ -282,7 +285,7 @@ def loadStats(self): self.saveFileManager.get_save_path("stats.json"), "r" ) as statsSaveFile: self.stats = self.statsJsonReaderWriter.readStatsFromFile(statsSaveFile) - except (IOError, OSError, json.JSONDecodeError) as e: + except (IOError, OSError, json.JSONDecodeError, ValidationError) as e: print(f"\n Warning: Failed to load stats data: {e}") print(" Creating new stats...") self.stats = Stats() @@ -297,7 +300,7 @@ def loadTimeService(self): timeServiceSaveFile, self.player, self.stats ) ) - except (IOError, OSError, json.JSONDecodeError) as e: + except (IOError, OSError, json.JSONDecodeError, ValidationError) as e: print(f"\n Warning: Failed to load time service data: {e}") print(" Creating new time service...") self.timeService = TimeService(self.player, self.stats) diff --git a/src/player/player.py b/src/player/player.py index 8e41f44..9650818 100644 --- a/src/player/player.py +++ b/src/player/player.py @@ -5,14 +5,17 @@ # @author Daniel McCoy Stephenson class Player: - def __init__(self): - self.fishCount = 0 - self.money = 20 - self.moneyInBank = 0.01 - self.fishMultiplier = 1 - self.priceForBait = 50 + def __init__(self, config=None): + self.fishCount = 0 if config is None else config.initialFishCount + self.money = 20 if config is None else config.initialMoney + self.moneyInBank = 0.01 if config is None else config.initialMoneyInBank + self.fishMultiplier = 1 if config is None else config.initialFishMultiplier + self.priceForBait = 50 if config is None else config.initialPriceForBait # Starts at the Homeless tier's energy cap (see src/housing) - a - # fresh player hasn't found anywhere to stay yet. + # fresh player hasn't found anywhere to stay yet. Deliberately not + # sourced from Config.initialEnergy: the housing ladder is the + # source of truth for energy caps (see src/housing/housing.py), and + # a flat configured starting energy could exceed the Homeless cap. self.energy = housing.HOUSING_TIERS[0]["maxEnergy"] self.rodLevel = 1 # Per-species breakdown of the fish currently held. fishCount remains the diff --git a/src/player/playerJsonReaderWriter.py b/src/player/playerJsonReaderWriter.py index 630ad63..677d615 100644 --- a/src/player/playerJsonReaderWriter.py +++ b/src/player/playerJsonReaderWriter.py @@ -1,5 +1,8 @@ import json from player.player import Player +from validation.schemaValidator import validate_against_schema + +PLAYER_SCHEMA_PATH = "schemas/player.json" class PlayerJsonReaderWriter: @@ -42,6 +45,13 @@ def createPlayerFromJson(self, playerJson): player.rentalProperties = playerJson.get( "rentalProperties", player.rentalProperties ) + + # Validate the resulting values (not the raw input) against the + # schema, so a save missing keys still loads via the defaults above + # (backwards compatibility), while an out-of-range value that was + # present (e.g. energy: -500) is caught here instead of surfacing as + # a ValueError deep in game logic later. + validate_against_schema(self.createJsonFromPlayer(player), PLAYER_SCHEMA_PATH) return player def writePlayerToFile(self, player, jsonFile): diff --git a/src/stats/statsJsonReaderWriter.py b/src/stats/statsJsonReaderWriter.py index 11e9554..b82be7e 100644 --- a/src/stats/statsJsonReaderWriter.py +++ b/src/stats/statsJsonReaderWriter.py @@ -1,5 +1,8 @@ import json from stats.stats import Stats +from validation.schemaValidator import validate_against_schema + +STATS_SCHEMA_PATH = "schemas/stats.json" class StatsJsonReaderWriter: @@ -64,6 +67,10 @@ def createStatsFromJson(self, statsJson): "totalPropertiesBought", stats.totalPropertiesBought ) stats.totalRentPaid = statsJson.get("totalRentPaid", stats.totalRentPaid) + + # Validate the resulting values (not the raw input) against the + # schema - see PlayerJsonReaderWriter.createPlayerFromJson for why. + validate_against_schema(self.createJsonFromStats(stats), STATS_SCHEMA_PATH) return stats def readStatsFromFile(self, statsJsonFile): diff --git a/src/validation/__init__.py b/src/validation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/validation/schemaValidator.py b/src/validation/schemaValidator.py new file mode 100644 index 0000000..8bb0d81 --- /dev/null +++ b/src/validation/schemaValidator.py @@ -0,0 +1,15 @@ +# @author Daniel McCoy Stephenson +import json + +from jsonschema import validate + + +def validate_against_schema(data, schema_path): + """Validate data against the JSON Schema at schema_path. + + Raises jsonschema.exceptions.ValidationError if data doesn't conform + (e.g. a value outside the range the schema declares as valid). + """ + with open(schema_path) as schema_file: + schema = json.load(schema_file) + validate(instance=data, schema=schema) diff --git a/src/world/timeServiceJsonReaderWriter.py b/src/world/timeServiceJsonReaderWriter.py index 7714ab4..2f67091 100644 --- a/src/world/timeServiceJsonReaderWriter.py +++ b/src/world/timeServiceJsonReaderWriter.py @@ -1,5 +1,8 @@ import json from world.timeService import TimeService +from validation.schemaValidator import validate_against_schema + +TIME_SERVICE_SCHEMA_PATH = "schemas/timeService.json" class TimeServiceJsonReaderWriter: @@ -13,6 +16,12 @@ def createTimeServiceFromJson(self, timeServiceJson, player, stats): timeService = TimeService(player, stats) timeService.time = timeServiceJson.get("time", timeService.time) timeService.day = timeServiceJson.get("day", timeService.day) + + # Validate the resulting values (not the raw input) against the + # schema - see PlayerJsonReaderWriter.createPlayerFromJson for why. + validate_against_schema( + self.createJsonFromTimeService(timeService), TIME_SERVICE_SCHEMA_PATH + ) return timeService def writeTimeServiceToFile(self, timeService, jsonFile): diff --git a/tests/player/test_player.py b/tests/player/test_player.py index abaf3e6..b8d8749 100644 --- a/tests/player/test_player.py +++ b/tests/player/test_player.py @@ -1,3 +1,4 @@ +from src.config.config import Config from src.housing import housing from src.player.player import Player @@ -25,6 +26,41 @@ def test_initialization(): assert player.rentalProperties == [] +def test_initialization_with_config_seeds_from_it(): + # prepare - a config with non-default initial values + config = Config() + config.initialMoney = 500 + config.initialFishCount = 3 + config.initialMoneyInBank = 10 + config.initialFishMultiplier = 2 + config.initialPriceForBait = 75 + + # call + player = Player(config) + + # check + assert player.money == 500 + assert player.fishCount == 3 + assert player.moneyInBank == 10 + assert player.fishMultiplier == 2 + assert player.priceForBait == 75 + # energy stays governed by the housing ladder regardless of config - + # see Player.__init__'s comment on why it isn't sourced from Config + assert player.energy == housing.HOUSING_TIERS[0]["maxEnergy"] + + +def test_initialization_without_config_uses_hardcoded_defaults(): + # call + player = Player(config=None) + + # check - unaffected by omitting config, same as createPlayer() + assert player.money == 20 + assert player.fishCount == 0 + assert player.moneyInBank == 0.01 + assert player.fishMultiplier == 1 + assert player.priceForBait == 50 + + def test_addFish_and_clearFish_keep_count_in_sync(): # prepare player = createPlayer() diff --git a/tests/player/test_playerJsonReaderWriter.py b/tests/player/test_playerJsonReaderWriter.py index 25bc8da..7610d97 100644 --- a/tests/player/test_playerJsonReaderWriter.py +++ b/tests/player/test_playerJsonReaderWriter.py @@ -1,7 +1,9 @@ from src.player import playerJsonReaderWriter from src.player.player import Player import json +import pytest from jsonschema import validate +from jsonschema.exceptions import ValidationError def createPlayerJsonReaderWriter(): @@ -354,3 +356,39 @@ def test_createPlayerFromJson_missingRentalProperties_defaultsToEmpty(): # check - backward compatible default assert player.rentalProperties == [] + + +def test_createPlayerFromJson_outOfRangeEnergy_raisesValidationError(): + # prepare - a syntactically valid but corrupted save (schemas/player.json + # caps energy at 200) + playerJsonReaderWriter = createPlayerJsonReaderWriter() + playerJson = { + "fishCount": 0, + "money": 20, + "moneyInBank": 0.01, + "fishMultiplier": 1, + "priceForBait": 50, + "energy": -500, + } + + # call/check + with pytest.raises(ValidationError): + playerJsonReaderWriter.createPlayerFromJson(playerJson) + + +def test_createPlayerFromJson_outOfRangeHomeTier_raisesValidationError(): + # prepare - homeTier only goes up to 5 per schemas/player.json + playerJsonReaderWriter = createPlayerJsonReaderWriter() + playerJson = { + "fishCount": 0, + "money": 20, + "moneyInBank": 0.01, + "fishMultiplier": 1, + "priceForBait": 50, + "energy": 100, + "homeTier": 99, + } + + # call/check + with pytest.raises(ValidationError): + playerJsonReaderWriter.createPlayerFromJson(playerJson) diff --git a/tests/stats/test_statsJsonReaderWriter.py b/tests/stats/test_statsJsonReaderWriter.py index 48b08b8..bd141a8 100644 --- a/tests/stats/test_statsJsonReaderWriter.py +++ b/tests/stats/test_statsJsonReaderWriter.py @@ -1,7 +1,9 @@ from src.stats.stats import Stats from src.stats import statsJsonReaderWriter import json +import pytest from jsonschema import validate +from jsonschema.exceptions import ValidationError def createStatsJsonReaderWriter(): @@ -294,3 +296,14 @@ def test_createStatsFromJson_missingAllFields_usesDefaults(): assert stats.timesGottenDrunk == defaults.timesGottenDrunk assert stats.moneyLostFromGambling == defaults.moneyLostFromGambling assert stats.moneyLostWhileDrunk == defaults.moneyLostWhileDrunk + + +def test_createStatsFromJson_wrongType_raisesValidationError(): + # prepare - a syntactically valid but corrupted save (schemas/stats.json + # declares totalFishCaught as an integer) + statsJsonReaderWriter = createStatsJsonReaderWriter() + statsJson = {"totalFishCaught": "not-a-number"} + + # call/check + with pytest.raises(ValidationError): + statsJsonReaderWriter.createStatsFromJson(statsJson) diff --git a/tests/test_fishE.py b/tests/test_fishE.py index a5fe021..ef3a850 100644 --- a/tests/test_fishE.py +++ b/tests/test_fishE.py @@ -12,6 +12,12 @@ from src.world.timeServiceJsonReaderWriter import TimeServiceJsonReaderWriter from src.saveFileManager import SaveFileManager +# Imported the same way fishE.py imports it (bare, not "src."-prefixed) so +# isinstance checks against fishE.FishE's real self.config compare the same +# module object - pytest.ini puts both "." and "src" on pythonpath, and +# these are two distinct module identities to Python. +from config.config import Config + def createFishE(): fishE.Player = MagicMock() @@ -31,16 +37,16 @@ def createFishE(): fishE.loadPlayer = MagicMock() fishE.loadStats = MagicMock() fishE.loadTimeService = MagicMock() - + # Mock the save file manager instance methods mock_save_manager = MagicMock() mock_save_manager.get_save_path.return_value = "data/player.json" mock_save_manager.list_save_files.return_value = [] mock_save_manager.get_next_available_slot.return_value = 1 fishE.SaveFileManager.return_value = mock_save_manager - + # Mock the _selectSaveFile method to avoid stdin interaction - with patch.object(fishE.FishE, '_selectSaveFile', return_value=None): + with patch.object(fishE.FishE, "_selectSaveFile", return_value=None): return fishE.FishE() @@ -76,11 +82,25 @@ def test_initialization(): fishE.SaveFileManager.assert_called_once() +def test_initialization_wires_config_into_saveFileManager_and_player(): + # call + fishEInstance = createFishE() + + # check - a real Config seeds the mocked SaveFileManager's data directory + # and is passed through to the (mocked) Player constructor + assert isinstance(fishEInstance.config, Config) + fishE.SaveFileManager.assert_called_once_with( + data_directory=fishEInstance.config.dataDirectory + ) + fishE.Player.assert_called_once_with(fishEInstance.config) + + def createGameForPersistence(data_directory): # Build a FishE without running __init__ (which drives stdin); attach real # collaborators and a temp-dir-backed save manager so save()/load*() exercise # real serialization against a real (temporary) save slot. game = fishE.FishE.__new__(fishE.FishE) + game.config = Config() game.playerJsonReaderWriter = PlayerJsonReaderWriter() game.statsJsonReaderWriter = StatsJsonReaderWriter() game.timeServiceJsonReaderWriter = TimeServiceJsonReaderWriter() @@ -205,6 +225,38 @@ def test_loadPlayer_recovers_from_corrupt_file(): assert game.player.fishCount == Player().fishCount +def test_loadPlayer_recovers_from_out_of_range_value(): + # restore the real Player so the except-path fallback builds a real player + fishE.Player = Player + + with tempfile.TemporaryDirectory() as data_directory: + # prepare - a syntactically valid save with an out-of-range value + # (homeTier only goes up to 5 per schemas/player.json) + game = createGameForPersistence(data_directory) + path = game.saveFileManager.get_save_path("player.json") + with open(path, "w") as f: + json.dump( + { + "fishCount": 0, + "money": 20, + "moneyInBank": 0.01, + "fishMultiplier": 1, + "priceForBait": 50, + "energy": 100, + "homeTier": 99, + }, + f, + ) + + # call - must not raise; falls back to a fresh player instead of + # loading a player whose homeTier housing.py can't resolve + game.loadPlayer() + + # check + assert isinstance(game.player, Player) + assert game.player.homeTier == Player().homeTier + + def test_selectSaveFile_new_game_selects_next_slot(): # prepare - no existing saves; choosing the only non-quit option creates one game = fishE.FishE.__new__(fishE.FishE) diff --git a/tests/validation/test_schemaValidator.py b/tests/validation/test_schemaValidator.py new file mode 100644 index 0000000..665fdf2 --- /dev/null +++ b/tests/validation/test_schemaValidator.py @@ -0,0 +1,30 @@ +import pytest +from jsonschema.exceptions import ValidationError + +from src.validation.schemaValidator import validate_against_schema + + +def _validPlayerJson(): + return { + "fishCount": 0, + "money": 20, + "moneyInBank": 0.01, + "fishMultiplier": 1, + "priceForBait": 50, + "energy": 100, + } + + +def test_validate_against_schema_passes_for_valid_data(): + # call/check - must not raise + validate_against_schema(_validPlayerJson(), "schemas/player.json") + + +def test_validate_against_schema_raises_for_out_of_range_data(): + # prepare + playerJson = _validPlayerJson() + playerJson["energy"] = -1 + + # call/check + with pytest.raises(ValidationError): + validate_against_schema(playerJson, "schemas/player.json") diff --git a/tests/world/test_timeServiceJsonReaderWriter.py b/tests/world/test_timeServiceJsonReaderWriter.py index 201ef26..c535c83 100644 --- a/tests/world/test_timeServiceJsonReaderWriter.py +++ b/tests/world/test_timeServiceJsonReaderWriter.py @@ -3,7 +3,9 @@ from src.world import timeServiceJsonReaderWriter from src.world.timeService import TimeService import json +import pytest from jsonschema import validate +from jsonschema.exceptions import ValidationError def createTimeServiceJsonReaderWriter(): @@ -88,9 +90,7 @@ def test_readTimeServiceFromFile(): timeServiceJson = {"time": 12, "day": 5} # Write test data to temp file - with tempfile.NamedTemporaryFile( - mode="w", delete=False, suffix=".json" - ) as f: + with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".json") as f: json.dump(timeServiceJson, f) temp_file_path = f.name @@ -129,3 +129,18 @@ def test_createTimeServiceFromJson_missingAllFields_usesDefaults(): defaults = TimeService(player, stats) assert timeService.time == defaults.time assert timeService.day == defaults.day + + +def test_createTimeServiceFromJson_outOfRangeTime_raisesValidationError(): + # prepare - a syntactically valid but corrupted save (schemas/timeService.json + # caps time at 23) + timeServiceJsonReaderWriter = createTimeServiceJsonReaderWriter() + player = Player() + stats = Stats() + timeServiceJson = {"time": 99, "day": 5} + + # call/check + with pytest.raises(ValidationError): + timeServiceJsonReaderWriter.createTimeServiceFromJson( + timeServiceJson, player, stats + )