From afbe183f79d45a74491f5fee7e5fa1fcda0ad940 Mon Sep 17 00:00:00 2001 From: Daniel McCoy Stephenson Date: Sat, 13 Jun 2026 13:41:21 -0600 Subject: [PATCH] Add a fishing business: buy a boat and hire a crew for passive catch Implements the PLANNING.md goals "open a fishing business" and "hire workers", turning accumulated money into ongoing production instead of just a number. - Player gains persisted hasBoat / workers fields (schema + reader/writer, with backward-compatible defaults for old saves). - New src/business module: each new day, every worker brings in a fixed catch for a daily wage; if the player can't cover payroll, the workers they can't pay quit (so a broke, over-hired business shrinks rather than going negative). Wired into TimeService.increaseDay alongside interest. - The docks gain a "Manage Boat & Crew" option (appended, so existing menu numbers are unchanged): buy a boat, hire/dismiss workers, and see the crew's daily economics. Co-Authored-By: Claude Sonnet 4.6 --- PLANNING.md | 2 - README.md | 3 + schemas/player.json | 7 ++ src/business/__init__.py | 0 src/business/business.py | 46 ++++++++++++ src/location/docks.py | 76 +++++++++++++++++++- src/player/player.py | 4 ++ src/player/playerJsonReaderWriter.py | 4 ++ src/world/timeService.py | 5 ++ tests/business/test_business.py | 75 +++++++++++++++++++ tests/location/test_docks.py | 79 +++++++++++++++++++++ tests/player/test_player.py | 2 + tests/player/test_playerJsonReaderWriter.py | 38 ++++++++++ tests/world/test_timeService.py | 18 +++++ 14 files changed, 356 insertions(+), 3 deletions(-) create mode 100644 src/business/__init__.py create mode 100644 src/business/business.py create mode 100644 tests/business/test_business.py diff --git a/PLANNING.md b/PLANNING.md index d8c6a3e..c4fcec5 100644 --- a/PLANNING.md +++ b/PLANNING.md @@ -3,8 +3,6 @@ You are a fisherman living in a home by the docks. There’s a store nearby that GOALS -- Make it possible to open a fishing business -- Make hiring workers a thing - FISH LOADING BAIT PRICE BUG Maybes diff --git a/README.md b/README.md index 174ce37..0c02a9b 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,9 @@ The entire game — save-file manager, fishing, shop, bank, tavern, and NPC dial ### Your Goal Build a fortune of **$10,000** in total wealth (cash on hand plus savings in the bank). Your progress toward the goal is shown in the status header, and reaching it earns a one-time victory — after which you're free to keep fishing or retire from the Home menu. +### Fishing Business +Once you can afford it, buy a **boat** at the docks ("Manage Boat & Crew") and **hire workers**. Each day your crew brings in a passive catch in exchange for a daily wage — turning saved-up money into ongoing production. If you over-hire and can't make payroll, the workers you can't pay quit, so keep enough cash on hand to cover wages. + ### Multiple Save Files FishE supports multiple save files, allowing you to maintain different game progressions simultaneously. When you start the game, you'll see a save file manager that displays: diff --git a/schemas/player.json b/schemas/player.json index d0f6d46..abb3be6 100644 --- a/schemas/player.json +++ b/schemas/player.json @@ -36,6 +36,13 @@ "type": "integer", "minimum": 0 } + }, + "hasBoat": { + "type": "boolean" + }, + "workers": { + "type": "integer", + "minimum": 0 } }, "required": [ diff --git a/src/business/__init__.py b/src/business/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/business/business.py b/src/business/business.py new file mode 100644 index 0000000..82fbf38 --- /dev/null +++ b/src/business/business.py @@ -0,0 +1,46 @@ +# @author Daniel McCoy Stephenson +# +# The fishing business: once the player owns a boat they can hire workers, who +# bring in a passive catch each day in exchange for a daily wage. This turns +# accumulated money into ongoing production rather than just a number that grows. + +BOAT_PRICE = 500 +MAX_WORKERS = 5 +WORKER_DAILY_WAGE = 10 +WORKER_FISH_PER_DAY = 6 +WORKER_CATCH_SPECIES = "Minnow" # workers bring in the common catch + + +def runDailyProduction(player, stats=None): + """Apply one day of the fishing business and return a summary. + + Each worker catches WORKER_FISH_PER_DAY fish for WORKER_DAILY_WAGE in wages. + If the player can't cover the full payroll, the workers they can't pay quit + (so an over-hired, broke business shrinks instead of going into debt).""" + summary = { + "workers": player.workers, + "fishCaught": 0, + "wagesPaid": 0, + "quit": 0, + } + if not player.hasBoat or player.workers <= 0: + return summary + + affordable = min(player.workers, int(player.money // WORKER_DAILY_WAGE)) + if affordable < player.workers: + summary["quit"] = player.workers - affordable + player.workers = affordable + summary["workers"] = player.workers + + if affordable <= 0: + return summary + + wages = affordable * WORKER_DAILY_WAGE + caught = affordable * WORKER_FISH_PER_DAY + player.money -= wages + player.addFish(WORKER_CATCH_SPECIES, caught) + summary["wagesPaid"] = wages + summary["fishCaught"] = caught + if stats is not None: + stats.totalFishCaught += caught + return summary diff --git a/src/location/docks.py b/src/location/docks.py index 691701a..238ae5d 100644 --- a/src/location/docks.py +++ b/src/location/docks.py @@ -10,6 +10,7 @@ from ui.userInterface import UserInterface from npc.npc import NPC from fish import fish +from business import business # The catch reaction window widens with the player's rod level, so a better rod @@ -84,7 +85,15 @@ def __init__( ) def run(self): - li = ["Fish", "Talk to %s" % self.npc.name, "Go Home", "Go to Shop", "Go to Tavern", "Go to Bank"] + li = [ + "Fish", + "Talk to %s" % self.npc.name, + "Go Home", + "Go to Shop", + "Go to Tavern", + "Go to Bank", + "Manage Boat & Crew", + ] input = self.userInterface.showOptions( "You breathe in the fresh air. Salty.", li ) @@ -120,6 +129,71 @@ def run(self): ) return LocationType.BANK + elif input == "7": + self.manageBusiness() + return LocationType.DOCKS + + def _businessStatus(self): + if not self.player.hasBoat: + return ( + "You have no boat. A boat lets you hire a crew that brings in a " + "passive catch each day. A boat costs $%d." % business.BOAT_PRICE + ) + return ( + "Your crew: %d/%d workers. Each catches %d fish per day for $%d in " + "wages, paid automatically every new day." + % ( + self.player.workers, + business.MAX_WORKERS, + business.WORKER_FISH_PER_DAY, + business.WORKER_DAILY_WAGE, + ) + ) + + def manageBusiness(self): + while True: + options = [] + actions = [] + if not self.player.hasBoat: + options.append("Buy a Boat ($%d)" % business.BOAT_PRICE) + actions.append("buy_boat") + else: + if self.player.workers < business.MAX_WORKERS: + options.append( + "Hire a Worker (+%d fish/day for $%d/day)" + % (business.WORKER_FISH_PER_DAY, business.WORKER_DAILY_WAGE) + ) + actions.append("hire") + if self.player.workers > 0: + options.append("Dismiss a Worker") + actions.append("dismiss") + options.append("Back") + actions.append("back") + + choice = int( + self.userInterface.showOptions(self._businessStatus(), options) + ) + action = actions[choice - 1] + + if action == "buy_boat": + if self.player.money >= business.BOAT_PRICE: + self.player.money -= business.BOAT_PRICE + self.player.hasBoat = True + self.currentPrompt.text = "You bought a boat! Now hire a crew." + else: + self.currentPrompt.text = "You can't afford a boat yet." + elif action == "hire": + self.player.workers += 1 + self.currentPrompt.text = ( + "You hired a worker. They'll fish each day for their wage." + ) + elif action == "dismiss": + self.player.workers -= 1 + self.currentPrompt.text = "You let a worker go." + elif action == "back": + self.currentPrompt.text = "What would you like to do?" + return + def getTimeOfDayModifier(self, hour): """Return (yield factor, flavour label) for fishing at the given hour. diff --git a/src/player/player.py b/src/player/player.py index 53228d1..b9e0a71 100644 --- a/src/player/player.py +++ b/src/player/player.py @@ -11,6 +11,10 @@ def __init__(self): # Per-species breakdown of the fish currently held. fishCount remains the # aggregate total; addFish/clearFish keep the two in sync. self.fishByType = {} + # Fishing business: a boat unlocks hiring workers who bring in a passive + # daily catch for a daily wage (see src/business). + self.hasBoat = False + self.workers = 0 def addFish(self, fishTypeName, amount): self.fishByType[fishTypeName] = self.fishByType.get(fishTypeName, 0) + amount diff --git a/src/player/playerJsonReaderWriter.py b/src/player/playerJsonReaderWriter.py index 081b8a6..39f235e 100644 --- a/src/player/playerJsonReaderWriter.py +++ b/src/player/playerJsonReaderWriter.py @@ -13,6 +13,8 @@ def createJsonFromPlayer(self, player): "energy": player.energy, "rodLevel": player.rodLevel, "fishByType": player.fishByType, + "hasBoat": player.hasBoat, + "workers": player.workers, } def createPlayerFromJson(self, playerJson): @@ -28,6 +30,8 @@ def createPlayerFromJson(self, playerJson): player.energy = playerJson.get("energy", player.energy) player.rodLevel = playerJson.get("rodLevel", player.rodLevel) player.fishByType = playerJson.get("fishByType", player.fishByType) + player.hasBoat = playerJson.get("hasBoat", player.hasBoat) + player.workers = playerJson.get("workers", player.workers) return player def writePlayerToFile(self, player, jsonFile): diff --git a/src/world/timeService.py b/src/world/timeService.py index 4c1e13a..2ecb179 100644 --- a/src/world/timeService.py +++ b/src/world/timeService.py @@ -1,5 +1,7 @@ import math +from business import business + # Daily bank interest is deliberately modest and capped so that saving is a # minor convenience rather than a way to bypass fishing entirely: sleeping is @@ -36,3 +38,6 @@ def increaseDay(self): self.player.moneyInBank += moneyToAdd self.stats.moneyMadeFromInterest += moneyToAdd self.stats.totalMoneyMade += moneyToAdd + + # The fishing business (if any) produces its daily catch and pays wages. + business.runDailyProduction(self.player, self.stats) diff --git a/tests/business/test_business.py b/tests/business/test_business.py new file mode 100644 index 0000000..2ba8381 --- /dev/null +++ b/tests/business/test_business.py @@ -0,0 +1,75 @@ +from src.business import business +from src.player.player import Player +from src.stats.stats import Stats + + +def test_no_production_without_a_boat(): + # prepare - workers but no boat + player = Player() + player.workers = 3 + player.money = 1000 + + # call + summary = business.runDailyProduction(player) + + # check - nothing happens until there's a boat + assert summary["fishCaught"] == 0 + assert summary["wagesPaid"] == 0 + assert player.money == 1000 + assert player.fishCount == 0 + + +def test_workers_catch_fish_and_draw_wages(): + # prepare - a boat and two workers, plenty of money + player = Player() + player.hasBoat = True + player.workers = 2 + player.money = 1000 + stats = Stats() + + # call + summary = business.runDailyProduction(player, stats) + + # check + expectedFish = 2 * business.WORKER_FISH_PER_DAY + expectedWages = 2 * business.WORKER_DAILY_WAGE + assert summary["fishCaught"] == expectedFish + assert summary["wagesPaid"] == expectedWages + assert player.fishCount == expectedFish + assert player.money == 1000 - expectedWages + assert stats.totalFishCaught == expectedFish + + +def test_unaffordable_workers_quit(): + # prepare - 3 workers but only enough money for one day's wage of one worker + player = Player() + player.hasBoat = True + player.workers = 3 + player.money = business.WORKER_DAILY_WAGE # covers exactly one worker + + # call + summary = business.runDailyProduction(player) + + # check - the two unpayable workers quit; the remaining one is paid and fishes + assert summary["quit"] == 2 + assert player.workers == 1 + assert summary["wagesPaid"] == business.WORKER_DAILY_WAGE + assert summary["fishCaught"] == business.WORKER_FISH_PER_DAY + assert player.money == 0 + + +def test_all_workers_quit_when_broke(): + # prepare - a boat and workers but no money + player = Player() + player.hasBoat = True + player.workers = 2 + player.money = 0 + + # call + summary = business.runDailyProduction(player) + + # check - everyone quits, nothing caught + assert player.workers == 0 + assert summary["quit"] == 2 + assert summary["fishCaught"] == 0 + assert player.fishCount == 0 diff --git a/tests/location/test_docks.py b/tests/location/test_docks.py index b96a416..6e17f05 100644 --- a/tests/location/test_docks.py +++ b/tests/location/test_docks.py @@ -335,3 +335,82 @@ def make_docks_with_rod(rodLevel): # check - the wider window of the better rod lands more catches assert results["highRod"] > results["lowRod"] + + +def test_run_manage_business_action(): + # prepare + docksInstance = createDocks() + docksInstance.userInterface.showOptions = MagicMock(return_value="7") + docksInstance.manageBusiness = MagicMock() + + # call + nextLocation = docksInstance.run() + + # check + assert nextLocation == LocationType.DOCKS + docksInstance.manageBusiness.assert_called_once() + + +def test_manageBusiness_buy_boat(): + # prepare - enough money for a boat; buy it, then go Back + from src.business import business + + docksInstance = createDocks() + docksInstance.player.money = business.BOAT_PRICE + 50 + docksInstance.player.hasBoat = False + # "1" = Buy a Boat; then in the post-purchase menu "2" = Back + docksInstance.userInterface.showOptions = MagicMock(side_effect=["1", "2"]) + + # call + docksInstance.manageBusiness() + + # check + assert docksInstance.player.hasBoat is True + assert docksInstance.player.money == 50 + + +def test_manageBusiness_buy_boat_insufficient_funds(): + # prepare - can't afford a boat + from src.business import business + + docksInstance = createDocks() + docksInstance.player.money = business.BOAT_PRICE - 1 + docksInstance.player.hasBoat = False + docksInstance.userInterface.showOptions = MagicMock(side_effect=["1", "2"]) + + # call + docksInstance.manageBusiness() + + # check - no boat, no money spent + assert docksInstance.player.hasBoat is False + assert docksInstance.player.money == business.BOAT_PRICE - 1 + + +def test_manageBusiness_hire_worker(): + # prepare - own a boat, no crew yet + docksInstance = createDocks() + docksInstance.player.hasBoat = True + docksInstance.player.workers = 0 + # "1" = Hire; then in the menu with a worker "3" = Back (Hire/Dismiss/Back) + docksInstance.userInterface.showOptions = MagicMock(side_effect=["1", "3"]) + + # call + docksInstance.manageBusiness() + + # check + assert docksInstance.player.workers == 1 + + +def test_manageBusiness_dismiss_worker(): + # prepare - own a boat with two workers + docksInstance = createDocks() + docksInstance.player.hasBoat = True + docksInstance.player.workers = 2 + # "2" = Dismiss (Hire/Dismiss/Back); then "3" = Back + docksInstance.userInterface.showOptions = MagicMock(side_effect=["2", "3"]) + + # call + docksInstance.manageBusiness() + + # check + assert docksInstance.player.workers == 1 diff --git a/tests/player/test_player.py b/tests/player/test_player.py index f44f1c5..069ef2b 100644 --- a/tests/player/test_player.py +++ b/tests/player/test_player.py @@ -17,6 +17,8 @@ def test_initialization(): assert player.energy == 100 assert player.rodLevel == 1 assert player.fishByType == {} + assert player.hasBoat is False + assert player.workers == 0 def test_addFish_and_clearFish_keep_count_in_sync(): diff --git a/tests/player/test_playerJsonReaderWriter.py b/tests/player/test_playerJsonReaderWriter.py index 2bc45da..3aa4acf 100644 --- a/tests/player/test_playerJsonReaderWriter.py +++ b/tests/player/test_playerJsonReaderWriter.py @@ -189,6 +189,44 @@ def test_fishByType_round_trips(): assert restored.fishByType == {"Bass": 3, "Marlin": 1} +def test_business_fields_round_trip(): + # prepare + playerJsonReaderWriter = createPlayerJsonReaderWriter() + player = Player() + player.hasBoat = True + player.workers = 3 + + # call + playerJson = playerJsonReaderWriter.createJsonFromPlayer(player) + restored = playerJsonReaderWriter.createPlayerFromJson(playerJson) + + # check + assert playerJson["hasBoat"] is True + assert playerJson["workers"] == 3 + assert restored.hasBoat is True + assert restored.workers == 3 + + +def test_createPlayerFromJson_missingBusinessFields_defaults(): + # prepare - an older save with no boat/workers fields + playerJsonReaderWriter = createPlayerJsonReaderWriter() + playerJson = { + "fishCount": 5, + "fishMultiplier": 2, + "money": 100, + "moneyInBank": 50, + "priceForBait": 75, + "energy": 80, + } + + # call + player = playerJsonReaderWriter.createPlayerFromJson(playerJson) + + # check - backward-compatible defaults + assert player.hasBoat is False + assert player.workers == 0 + + def test_createPlayerFromJson_missingFishByType_defaultsToEmpty(): # prepare - an older save with no fishByType field playerJsonReaderWriter = createPlayerJsonReaderWriter() diff --git a/tests/world/test_timeService.py b/tests/world/test_timeService.py index e9f4608..b3c3933 100644 --- a/tests/world/test_timeService.py +++ b/tests/world/test_timeService.py @@ -85,3 +85,21 @@ def test_increaseDay_interest_is_capped(): assert timeService.stats.moneyMadeFromInterest == MAX_INTEREST_PER_DAY assert timeService.player.moneyInBank == 1000000 + MAX_INTEREST_PER_DAY assert timeService.stats.totalMoneyMade == MAX_INTEREST_PER_DAY + + +def test_increaseDay_runs_business_production(): + # prepare - a boat and one worker, no bank balance (isolate from interest) + from src.business import business + + timeService = createTimeService() + timeService.player.hasBoat = True + timeService.player.workers = 1 + timeService.player.money = 1000 + timeService.player.moneyInBank = 0 + + # call + timeService.increaseDay() + + # check - the worker fished and was paid as part of the day rollover + assert timeService.player.fishCount == business.WORKER_FISH_PER_DAY + assert timeService.player.money == 1000 - business.WORKER_DAILY_WAGE