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
15 changes: 9 additions & 6 deletions src/fishE.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
Expand All @@ -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?")
Expand Down Expand Up @@ -271,18 +274,18 @@ 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:
with open(
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()
Expand All @@ -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)
Expand Down
17 changes: 10 additions & 7 deletions src/player/player.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions src/player/playerJsonReaderWriter.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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):
Expand Down
7 changes: 7 additions & 0 deletions src/stats/statsJsonReaderWriter.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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):
Expand Down
Empty file added src/validation/__init__.py
Empty file.
15 changes: 15 additions & 0 deletions src/validation/schemaValidator.py
Original file line number Diff line number Diff line change
@@ -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)
9 changes: 9 additions & 0 deletions src/world/timeServiceJsonReaderWriter.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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):
Expand Down
36 changes: 36 additions & 0 deletions tests/player/test_player.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from src.config.config import Config
from src.housing import housing
from src.player.player import Player

Expand Down Expand Up @@ -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()
Expand Down
38 changes: 38 additions & 0 deletions tests/player/test_playerJsonReaderWriter.py
Original file line number Diff line number Diff line change
@@ -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():
Expand Down Expand Up @@ -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)
13 changes: 13 additions & 0 deletions tests/stats/test_statsJsonReaderWriter.py
Original file line number Diff line number Diff line change
@@ -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():
Expand Down Expand Up @@ -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)
58 changes: 55 additions & 3 deletions tests/test_fishE.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()


Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading