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
4 changes: 4 additions & 0 deletions schemas/timeService.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
"type": "number",
"minimum": 0,
"maximum": 23
},
"weather": {
"type": "string",
"enum": ["clear", "rainy", "stormy"]
}
}
}
32 changes: 31 additions & 1 deletion src/location/docks.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ def run(self):
)
else:
descriptor = "You breathe in the fresh air. Salty."
descriptor += " " + self._weatherDescriptor()
input = self.userInterface.showOptions(descriptor, li)

if input == "1":
Expand Down Expand Up @@ -281,6 +282,17 @@ def manageBusiness(self):
self.currentPrompt.text = "What would you like to do?"
return

def _weatherDescriptor(self):
"""Flavour text for the current day's weather, shown alongside the
docks descriptor so the player can factor it into the fish/rest
decision before casting a line."""
descriptors = {
"clear": "The sky is clear.",
"rainy": "Rain is falling steadily.",
"stormy": "Storm clouds churn overhead.",
}
return descriptors.get(self.timeService.weather, "")

def _renameBusiness(self):
name = self.userInterface.promptForText(
"What would you like to name your fishing business?"
Expand All @@ -307,6 +319,18 @@ def getTimeOfDayModifier(self, hour):
return 0.6, "The midday sun keeps the fish deep."
return 1.0, ""

def getWeatherModifier(self, weather):
"""Return (yield factor, flavour label) for fishing in the given
weather, in the same (factor, label) shape as getTimeOfDayModifier.

Rain stirs up feeding activity while a storm makes the water too
rough to fish well; clear weather is neutral."""
if weather == "rainy":
return 1.3, "The rain has the fish biting eagerly!"
if weather == "stormy":
return 0.5, "The stormy seas make for tough fishing."
return 1.0, ""

def fish(self):
self.userInterface.lotsOfSpace()
self.userInterface.divider()
Expand All @@ -317,6 +341,7 @@ def fish(self):

# Capture the time of day at the start of the trip (the loop advances it).
timeFactor, timeLabel = self.getTimeOfDayModifier(self.timeService.time)
weatherFactor, weatherLabel = self.getWeatherModifier(self.timeService.weather)

hours = random.randint(1, 10)

Expand Down Expand Up @@ -360,7 +385,9 @@ def fish(self):
self.player.spendEnergy(10) # Consume 10 energy per hour

baseFish = random.randint(1, 10)
fishToAdd = int(baseFish * quality * self.player.fishMultiplier * timeFactor)
fishToAdd = int(
baseFish * quality * self.player.fishMultiplier * timeFactor * weatherFactor
)
if fishToAdd == 0:
fishToAdd = 1 # always land at least one fish for the effort

Expand All @@ -379,6 +406,9 @@ def fish(self):
if timeLabel:
self.currentPrompt.text += " " + timeLabel

if weatherLabel:
self.currentPrompt.text += " " + weatherLabel

if evicted:
self.currentPrompt.text += " " + housing.EVICTION_MESSAGE

Expand Down
8 changes: 8 additions & 0 deletions src/world/timeService.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import math
import random

from business import business
from housing import housing
Expand All @@ -12,6 +13,11 @@
INTEREST_RATE = 0.02
MAX_INTEREST_PER_DAY = 50

# Weather rolls fresh each day (see increaseDay) so it stays unpredictable in
# a way the fully-known time-of-day windows aren't - see Docks.getWeatherModifier
# for how each option affects the day's catch.
WEATHER_OPTIONS = ["clear", "rainy", "stormy"]


# @author Daniel McCoy Stephenson
class TimeService:
Expand All @@ -21,6 +27,7 @@ def __init__(self, player, stats):

self.day = 1
self.time = 8
self.weather = "clear"

def increaseTime(self):
"""Advance the clock by an hour. Returns {"evicted": bool} so callers
Expand All @@ -41,6 +48,7 @@ def increaseDay(self):
Returns {"evicted": bool} - see housing.runDailyRent."""
self.time = 8
self.day += 1
self.weather = random.choice(WEATHER_OPTIONS)

moneyToAdd = int(math.ceil(self.player.moneyInBank * INTEREST_RATE))
moneyToAdd = min(moneyToAdd, MAX_INTEREST_PER_DAY)
Expand Down
7 changes: 6 additions & 1 deletion src/world/timeServiceJsonReaderWriter.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@

class TimeServiceJsonReaderWriter:
def createJsonFromTimeService(self, timeService):
return {"time": timeService.time, "day": timeService.day}
return {
"time": timeService.time,
"day": timeService.day,
"weather": timeService.weather,
}

def createTimeServiceFromJson(self, timeServiceJson, player, stats):
# Read each field with a fallback to the freshly-constructed
Expand All @@ -16,6 +20,7 @@ def createTimeServiceFromJson(self, timeServiceJson, player, stats):
timeService = TimeService(player, stats)
timeService.time = timeServiceJson.get("time", timeService.time)
timeService.day = timeServiceJson.get("day", timeService.day)
timeService.weather = timeServiceJson.get("weather", timeService.weather)

# Validate the resulting values (not the raw input) against the
# schema - see PlayerJsonReaderWriter.createPlayerFromJson for why.
Expand Down
82 changes: 80 additions & 2 deletions tests/location/test_docks.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,6 @@ def test_npc_business_dialogue_staged_by_empty_crew():

def test_npc_business_dialogue_staged_by_tier():
# prepare - one crewed boat per tier
from src.business import business

responses = {}
for tier in (1, 2, 3):
docksInstance = createDocks()
Expand Down Expand Up @@ -393,6 +391,86 @@ def test_getTimeOfDayModifier_windows():
assert nightFactor == 1.0 and nightLabel == ""


def test_getWeatherModifier_options():
# prepare
docksInstance = createDocks()

# check - rain boosts the catch, storms suppress it, clear is neutral
rainyFactor, rainyLabel = docksInstance.getWeatherModifier("rainy")
stormyFactor, stormyLabel = docksInstance.getWeatherModifier("stormy")
clearFactor, clearLabel = docksInstance.getWeatherModifier("clear")

assert rainyFactor > 1.0 and rainyLabel
assert stormyFactor < 1.0 and stormyLabel
assert clearFactor == 1.0 and clearLabel == ""


def test_fish_applies_weather_modifier():
# prepare - fish in a storm (penalty) vs rain (bonus) with identical rolls
def make_docks_in(weather):
d = createDocks()
d.userInterface.lotsOfSpace = MagicMock()
d.userInterface.divider = MagicMock()
d.timeService.weather = weather
return d

results = {}
for weather in ("stormy", "rainy"):
docksInstance = make_docks_in(weather)
docksInstance.userInterface.timedKeyPress = MagicMock(return_value=0.5)
with patch("src.location.docks.print"), patch(
"src.location.docks.sys.stdout.flush"
), patch("src.location.docks.time.sleep"), patch(
"src.location.docks.random.randint", side_effect=[5, 10]
): # 5 hours, baseFish 10
docksInstance.timeService.increaseTime = MagicMock(
return_value={"evicted": False}
)
docksInstance.fish()
results[weather] = docksInstance.player.fishCount

# check - the storm penalty yields fewer fish than the rain bonus
assert results["stormy"] < results["rainy"]


def test_fish_mentions_weather_label():
# prepare
docksInstance = createDocks()
docksInstance.userInterface.lotsOfSpace = MagicMock()
docksInstance.userInterface.divider = MagicMock()
docksInstance.userInterface.timedKeyPress = MagicMock(return_value=0.5)
docksInstance.timeService.weather = "rainy"

with patch("src.location.docks.print"), patch(
"src.location.docks.sys.stdout.flush"
), patch("src.location.docks.time.sleep"), patch(
"src.location.docks.random.randint", side_effect=[3, 6]
):
docksInstance.timeService.increaseTime = MagicMock(
return_value={"evicted": False}
)

# call
docksInstance.fish()

# check
assert "rain" in docksInstance.currentPrompt.text.lower()


def test_run_descriptor_mentions_current_weather():
# prepare
docksInstance = createDocks()
docksInstance.timeService.weather = "stormy"
docksInstance.userInterface.showOptions = MagicMock(return_value="3")

# call
docksInstance.run()

# check
descriptor = docksInstance.userInterface.showOptions.call_args[0][0]
assert "Storm" in descriptor


def test_fish_applies_time_of_day_modifier():
# prepare - fish at midday (penalty) vs dawn (bonus) with identical rolls
def make_docks_at(hour):
Expand Down
20 changes: 20 additions & 0 deletions tests/world/test_timeService.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from unittest.mock import patch

from src.player.player import Player
from src.stats.stats import Stats
from src.world.timeService import TimeService
Expand All @@ -18,6 +20,7 @@ def test_initialization():
expected_time = 8
assert timeService.day == expected_day
assert timeService.time == expected_time
assert timeService.weather == "clear"


def test_increaseTime():
Expand Down Expand Up @@ -180,3 +183,20 @@ def test_increaseDay_evicts_when_rent_is_unaffordable():
# check - evicted back to homeless as part of the day rollover
assert timeService.player.homeTier == 0
assert timeService.player.money == 0


def test_increaseDay_rolls_new_weather():
# prepare
from src.world.timeService import WEATHER_OPTIONS

timeService = createTimeService()

# call
with patch(
"src.world.timeService.random.choice", return_value="stormy"
) as mockChoice:
timeService.increaseDay()

# check - weather was rolled from the documented option pool
mockChoice.assert_called_once_with(WEATHER_OPTIONS)
assert timeService.weather == "stormy"
25 changes: 22 additions & 3 deletions tests/world/test_timeServiceJsonReaderWriter.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ def test_createJsonFromTimeService():

def test_createTimeServiceFromJson():
timeServiceJsonReaderWriter = createTimeServiceJsonReaderWriter()
timeServiceJson = {"time": 8, "day": 1}
timeServiceJson = {"time": 8, "day": 1, "weather": "rainy"}

# validate
timeServiceSchema = getTimeServiceSchema()
Expand All @@ -54,6 +54,7 @@ def test_createTimeServiceFromJson():
timeServiceJson, player, stats
)
assert timeServiceFromJson != None
assert timeServiceFromJson.weather == "rainy"


def test_writeTimeServiceToFile():
Expand All @@ -64,6 +65,7 @@ def test_writeTimeServiceToFile():
timeService = createTimeService()
timeService.time = 15
timeService.day = 10
timeService.weather = "stormy"

# call
with tempfile.NamedTemporaryFile(mode="w+", delete=False, suffix=".json") as f:
Expand All @@ -76,6 +78,7 @@ def test_writeTimeServiceToFile():

assert timeServiceJson["time"] == 15
assert timeServiceJson["day"] == 10
assert timeServiceJson["weather"] == "stormy"

# cleanup
import os
Expand All @@ -87,7 +90,7 @@ def test_readTimeServiceFromFile():
# prepare
import tempfile

timeServiceJson = {"time": 12, "day": 5}
timeServiceJson = {"time": 12, "day": 5, "weather": "rainy"}

# Write test data to temp file
with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".json") as f:
Expand All @@ -106,6 +109,7 @@ def test_readTimeServiceFromFile():
# check
assert timeService.time == 12
assert timeService.day == 5
assert timeService.weather == "rainy"

# cleanup
import os
Expand All @@ -125,10 +129,11 @@ def test_createTimeServiceFromJson_missingAllFields_usesDefaults():
timeServiceJson, player, stats
)

# check - both fields fall back to the TimeService() default
# check - all fields fall back to the TimeService() default
defaults = TimeService(player, stats)
assert timeService.time == defaults.time
assert timeService.day == defaults.day
assert timeService.weather == defaults.weather


def test_createTimeServiceFromJson_outOfRangeTime_raisesValidationError():
Expand All @@ -144,3 +149,17 @@ def test_createTimeServiceFromJson_outOfRangeTime_raisesValidationError():
timeServiceJsonReaderWriter.createTimeServiceFromJson(
timeServiceJson, player, stats
)


def test_createTimeServiceFromJson_unknownWeather_raisesValidationError():
# prepare - a corrupted save with a weather value outside the schema's enum
timeServiceJsonReaderWriter = createTimeServiceJsonReaderWriter()
player = Player()
stats = Stats()
timeServiceJson = {"time": 8, "day": 5, "weather": "sunny_with_dragons"}

# call/check
with pytest.raises(ValidationError):
timeServiceJsonReaderWriter.createTimeServiceFromJson(
timeServiceJson, player, stats
)
Loading