From 3fb900eca5be356a8d71b3711cdabf0070e3b573 Mon Sep 17 00:00:00 2001 From: Daniel McCoy Stephenson Date: Sat, 4 Jul 2026 13:03:59 -0600 Subject: [PATCH 1/3] Give the fishing business a tycoon-style identity and progression Replaces the flat one-time boat purchase with a named business that grows: - Boat tiers (Rowboat -> Trawler -> Fishing Fleet) raise crew capacity and each worker's daily catch as you upgrade, via a data-driven BOAT_TIERS list - Players can name (and rename) their business; it shows up in the Docks status line and flavor text - New lifetime stats (days in business, workers hired, fish caught by crew, wages paid) surface on the Home stats screen - Three new milestones (First Mate, Full Crew, Fishing Fleet Tycoon) reward business growth Player/Stats gain boatTier, businessName, and four lifetime counters, all following the existing get-with-default JSON fallback pattern so old saves load unaffected (currentTier() treats an unset boatTier as tier 1). --- schemas/player.json | 7 ++ schemas/stats.json | 16 +++ src/achievements/achievements.py | 18 +++ src/business/business.py | 36 +++++- src/location/docks.py | 80 ++++++++++--- src/location/home.py | 11 ++ src/player/player.py | 5 +- src/player/playerJsonReaderWriter.py | 4 + src/stats/stats.py | 6 + src/stats/statsJsonReaderWriter.py | 16 +++ tests/business/test_business.py | 61 ++++++++++ tests/location/test_docks.py | 121 +++++++++++++++++++- tests/player/test_playerJsonReaderWriter.py | 21 ++++ tests/stats/test_statsJsonReaderWriter.py | 42 +++++++ 14 files changed, 421 insertions(+), 23 deletions(-) diff --git a/schemas/player.json b/schemas/player.json index abb3be6..f9bc1d0 100644 --- a/schemas/player.json +++ b/schemas/player.json @@ -43,6 +43,13 @@ "workers": { "type": "integer", "minimum": 0 + }, + "boatTier": { + "type": "integer", + "minimum": 0 + }, + "businessName": { + "type": "string" } }, "required": [ diff --git a/schemas/stats.json b/schemas/stats.json index 40b87d9..cf180af 100644 --- a/schemas/stats.json +++ b/schemas/stats.json @@ -35,6 +35,22 @@ "type": "string" }, "description": "Names of milestones the player has already been awarded." + }, + "totalWorkersHired": { + "type": "integer", + "description": "The lifetime number of workers hired for the fishing business." + }, + "totalFishCaughtByCrew": { + "type": "integer", + "description": "The lifetime number of fish caught by the fishing business' crew." + }, + "totalWagesPaid": { + "type": "number", + "description": "The lifetime total of wages paid to the fishing business' crew." + }, + "daysInBusiness": { + "type": "integer", + "description": "The number of days the fishing business has produced a catch." } } } \ No newline at end of file diff --git a/src/achievements/achievements.py b/src/achievements/achievements.py index 50bb80f..a7b266b 100644 --- a/src/achievements/achievements.py +++ b/src/achievements/achievements.py @@ -43,6 +43,24 @@ "threshold": 50, "description": "Spend 50 hours fishing", }, + { + "name": "First Mate", + "stat": "totalWorkersHired", + "threshold": 1, + "description": "Hire your first crew member", + }, + { + "name": "Full Crew", + "stat": "totalWorkersHired", + "threshold": 5, + "description": "Hire 5 crew members over your career", + }, + { + "name": "Fishing Fleet Tycoon", + "stat": "totalFishCaughtByCrew", + "threshold": 500, + "description": "Have your crew catch 500 fish total", + }, ] diff --git a/src/business/business.py b/src/business/business.py index 1900e1f..ae42629 100644 --- a/src/business/business.py +++ b/src/business/business.py @@ -11,6 +11,31 @@ WORKER_DAILY_WAGE = 10 WORKER_FISH_PER_DAY = 5 +# Boat upgrades: a bigger boat holds more crew and each worker lands more fish +# per day. Tier 1 is exactly the original flat boat/crew numbers above, so +# existing saves and behavior are unchanged until a player chooses to upgrade. +BOAT_TIERS = [ + { + "name": "Rowboat", + "cost": BOAT_PRICE, + "maxWorkers": MAX_WORKERS, + "fishPerDay": WORKER_FISH_PER_DAY, + }, + {"name": "Trawler", "cost": 2000, "maxWorkers": 8, "fishPerDay": 7}, + {"name": "Fishing Fleet", "cost": 6000, "maxWorkers": 12, "fishPerDay": 10}, +] + + +def currentTier(player): + """Resolve the player's effective boat tier (always >= 1 once they own a + boat). Older saves/tests may set hasBoat without ever touching boatTier, + so an unset (0) tier is treated as tier 1 - the original boat.""" + return player.boatTier if player.boatTier > 0 else 1 + + +def tierInfo(tier): + return BOAT_TIERS[tier - 1] + def runDailyProduction(player, stats=None): """Apply one day of the fishing business and return a summary. @@ -43,13 +68,18 @@ def runDailyProduction(player, stats=None): player.spendMoney(wages) # Each worker fishes the same waters as the player, landing a rarity-rolled # species (not just the cheapest one), so the crew's income is competitive - # with simply upgrading your own gear. + # with simply upgrading your own gear. A bigger boat means a bigger catch + # per worker, not just more worker slots. + fishPerWorker = tierInfo(currentTier(player))["fishPerDay"] caught = 0 for _ in range(affordable): - player.addFish(fish.rollFishType(), WORKER_FISH_PER_DAY) - caught += WORKER_FISH_PER_DAY + player.addFish(fish.rollFishType(), fishPerWorker) + caught += fishPerWorker summary["wagesPaid"] = wages summary["fishCaught"] = caught if stats is not None: stats.totalFishCaught += caught + stats.totalFishCaughtByCrew += caught + stats.totalWagesPaid += wages + stats.daysInBusiness += 1 return summary diff --git a/src/location/docks.py b/src/location/docks.py index 3b41ce9..9d434bc 100644 --- a/src/location/docks.py +++ b/src/location/docks.py @@ -94,9 +94,11 @@ def run(self): "Go to Bank", "Manage Boat & Crew", ] - input = self.userInterface.showOptions( - "You breathe in the fresh air. Salty.", li - ) + if self.player.hasBoat and self.player.businessName: + descriptor = "%s is docked and ready for the day." % self.player.businessName + else: + descriptor = "You breathe in the fresh air. Salty." + input = self.userInterface.showOptions(descriptor, li) if input == "1": if self.player.hasEnergy(10): @@ -135,17 +137,27 @@ def run(self): def _businessStatus(self): if not self.player.hasBoat: + starter = business.tierInfo(1) 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 + "passive catch each day. A %s costs $%d." + % (starter["name"], starter["cost"]) ) + tier = business.currentTier(self.player) + info = business.tierInfo(tier) + name = self.player.businessName or "Unnamed Fishing Co." return ( - "Your crew: %d/%d workers. Each catches %d fish per day for $%d in " + "%s - %s (tier %d/%d)\n" + "Crew: %d/%d workers. Each catches %d fish per day for $%d in " "wages, paid automatically every new day." % ( + name, + info["name"], + tier, + len(business.BOAT_TIERS), self.player.workers, - business.MAX_WORKERS, - business.WORKER_FISH_PER_DAY, + info["maxWorkers"], + info["fishPerDay"], business.WORKER_DAILY_WAGE, ) ) @@ -155,18 +167,29 @@ def manageBusiness(self): options = [] actions = [] if not self.player.hasBoat: - options.append("Buy a Boat ($%d)" % business.BOAT_PRICE) + starter = business.tierInfo(1) + options.append("Buy a %s ($%d)" % (starter["name"], starter["cost"])) actions.append("buy_boat") else: - if self.player.workers < business.MAX_WORKERS: + tier = business.currentTier(self.player) + info = business.tierInfo(tier) + if self.player.workers < info["maxWorkers"]: options.append( "Hire a Worker (+%d fish/day for $%d/day)" - % (business.WORKER_FISH_PER_DAY, business.WORKER_DAILY_WAGE) + % (info["fishPerDay"], business.WORKER_DAILY_WAGE) ) actions.append("hire") if self.player.workers > 0: options.append("Dismiss a Worker") actions.append("dismiss") + if tier < len(business.BOAT_TIERS): + nextInfo = business.tierInfo(tier + 1) + options.append( + "Upgrade to a %s ($%d)" % (nextInfo["name"], nextInfo["cost"]) + ) + actions.append("upgrade_boat") + options.append("Rename Business") + actions.append("rename") options.append("Back") actions.append("back") @@ -176,24 +199,55 @@ def manageBusiness(self): action = actions[choice - 1] if action == "buy_boat": - if self.player.canAfford(business.BOAT_PRICE): - self.player.spendMoney(business.BOAT_PRICE) + starter = business.tierInfo(1) + if self.player.canAfford(starter["cost"]): + self.player.spendMoney(starter["cost"]) self.player.hasBoat = True - self.currentPrompt.text = "You bought a boat! Now hire a crew." + self.player.boatTier = 1 + self.currentPrompt.text = ( + "You bought a %s! Now hire a crew." % starter["name"] + ) else: self.currentPrompt.text = "You can't afford a boat yet." elif action == "hire": self.player.workers += 1 + self.stats.totalWorkersHired += 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 == "upgrade_boat": + tier = business.currentTier(self.player) + nextInfo = business.tierInfo(tier + 1) + if self.player.canAfford(nextInfo["cost"]): + self.player.spendMoney(nextInfo["cost"]) + self.player.boatTier = tier + 1 + self.currentPrompt.text = ( + "You upgraded to a %s!" % nextInfo["name"] + ) + else: + self.currentPrompt.text = "You can't afford that upgrade yet." + elif action == "rename": + self._renameBusiness() elif action == "back": self.currentPrompt.text = "What would you like to do?" return + def _renameBusiness(self): + name = self.userInterface.promptForText( + "What would you like to name your fishing business?" + ) + name = (name or "").strip()[:40] + if name: + self.player.businessName = name + self.currentPrompt.text = "Your business is now known as %s!" % name + else: + self.currentPrompt.text = "Never mind - the name stays %s." % ( + self.player.businessName or "Unnamed Fishing Co." + ) + def getTimeOfDayModifier(self, hour): """Return (yield factor, flavour label) for fishing at the given hour. diff --git a/src/location/home.py b/src/location/home.py index 2a77e80..7b0ec6d 100644 --- a/src/location/home.py +++ b/src/location/home.py @@ -57,6 +57,17 @@ def displayStats(self): "Money Made From Interest: %d" % self.stats.moneyMadeFromInterest, "Times Gotten Drunk: %d" % self.stats.timesGottenDrunk, "Money Lost Gambling: %d" % self.stats.moneyLostFromGambling, + ] + if self.player.hasBoat: + lines += [ + "", + "Business: %s" % (self.player.businessName or "Unnamed Fishing Co."), + "Days in Business: %d" % self.stats.daysInBusiness, + "Crew Hired (lifetime): %d" % self.stats.totalWorkersHired, + "Fish Caught by Crew: %d" % self.stats.totalFishCaughtByCrew, + "Wages Paid: %d" % self.stats.totalWagesPaid, + ] + lines += [ "", "Milestones:", ] diff --git a/src/player/player.py b/src/player/player.py index 875e1b1..9e2fe4b 100644 --- a/src/player/player.py +++ b/src/player/player.py @@ -15,9 +15,12 @@ def __init__(self): # 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). + # daily catch for a daily wage (see src/business). boatTier tracks boat + # upgrades (0 = no boat); businessName is purely cosmetic ownership flavor. self.hasBoat = False self.workers = 0 + self.boatTier = 0 + self.businessName = "" # Testing/debug cheat: when on, every money check passes and spendMoney # becomes a no-op, so cash never actually runs out. Not persisted to save # files - it's a runtime toggle, not game progress. Defaults from the diff --git a/src/player/playerJsonReaderWriter.py b/src/player/playerJsonReaderWriter.py index 39f235e..068310a 100644 --- a/src/player/playerJsonReaderWriter.py +++ b/src/player/playerJsonReaderWriter.py @@ -15,6 +15,8 @@ def createJsonFromPlayer(self, player): "fishByType": player.fishByType, "hasBoat": player.hasBoat, "workers": player.workers, + "boatTier": player.boatTier, + "businessName": player.businessName, } def createPlayerFromJson(self, playerJson): @@ -32,6 +34,8 @@ def createPlayerFromJson(self, playerJson): player.fishByType = playerJson.get("fishByType", player.fishByType) player.hasBoat = playerJson.get("hasBoat", player.hasBoat) player.workers = playerJson.get("workers", player.workers) + player.boatTier = playerJson.get("boatTier", player.boatTier) + player.businessName = playerJson.get("businessName", player.businessName) return player def writePlayerToFile(self, player, jsonFile): diff --git a/src/stats/stats.py b/src/stats/stats.py index 014aaa3..3181a89 100644 --- a/src/stats/stats.py +++ b/src/stats/stats.py @@ -9,3 +9,9 @@ def __init__(self): self.moneyLostFromGambling = 0 self.moneyLostWhileDrunk = 0 self.earnedMilestones = [] + # Lifetime fishing-business totals, tracked so players can see the + # impact of the business they've built (see src/business). + self.totalWorkersHired = 0 + self.totalFishCaughtByCrew = 0 + self.totalWagesPaid = 0 + self.daysInBusiness = 0 diff --git a/src/stats/statsJsonReaderWriter.py b/src/stats/statsJsonReaderWriter.py index d06d20d..c501b38 100644 --- a/src/stats/statsJsonReaderWriter.py +++ b/src/stats/statsJsonReaderWriter.py @@ -13,6 +13,10 @@ def createJsonFromStats(self, stats: Stats): "moneyLostFromGambling": stats.moneyLostFromGambling, "moneyLostWhileDrunk": stats.moneyLostWhileDrunk, "earnedMilestones": stats.earnedMilestones, + "totalWorkersHired": stats.totalWorkersHired, + "totalFishCaughtByCrew": stats.totalFishCaughtByCrew, + "totalWagesPaid": stats.totalWagesPaid, + "daysInBusiness": stats.daysInBusiness, } def createStatsFromJson(self, statsJson): @@ -40,6 +44,18 @@ def createStatsFromJson(self, statsJson): stats.earnedMilestones = statsJson.get( "earnedMilestones", stats.earnedMilestones ) + stats.totalWorkersHired = statsJson.get( + "totalWorkersHired", stats.totalWorkersHired + ) + stats.totalFishCaughtByCrew = statsJson.get( + "totalFishCaughtByCrew", stats.totalFishCaughtByCrew + ) + stats.totalWagesPaid = statsJson.get( + "totalWagesPaid", stats.totalWagesPaid + ) + stats.daysInBusiness = statsJson.get( + "daysInBusiness", stats.daysInBusiness + ) return stats def readStatsFromFile(self, statsJsonFile): diff --git a/tests/business/test_business.py b/tests/business/test_business.py index 192ba02..0f4f8c9 100644 --- a/tests/business/test_business.py +++ b/tests/business/test_business.py @@ -91,3 +91,64 @@ def test_all_workers_quit_when_broke(): assert summary["quit"] == 2 assert summary["fishCaught"] == 0 assert player.fishCount == 0 + + +def test_currentTier_defaults_to_1_when_unset(): + # prepare - an older save/test that sets hasBoat without ever touching + # boatTier + player = Player() + player.hasBoat = True + + # check - treated as the original (tier 1) boat + assert player.boatTier == 0 + assert business.currentTier(player) == 1 + + +def test_currentTier_reflects_upgrades(): + # prepare + player = Player() + player.hasBoat = True + player.boatTier = 2 + + # check + assert business.currentTier(player) == 2 + + +def test_higher_tier_boat_yields_more_fish_per_worker(): + # prepare - an upgraded boat (tier 2) with one worker + player = Player() + player.hasBoat = True + player.boatTier = 2 + player.workers = 1 + player.money = 1000 + stats = Stats() + + # call + summary = business.runDailyProduction(player, stats) + + # check - tier 2's fishPerDay beats the flat tier-1 constant + tier2FishPerDay = business.tierInfo(2)["fishPerDay"] + assert tier2FishPerDay > business.WORKER_FISH_PER_DAY + assert summary["fishCaught"] == tier2FishPerDay + assert stats.totalFishCaughtByCrew == tier2FishPerDay + + +def test_runDailyProduction_tracks_lifetime_business_stats(): + # prepare + player = Player() + player.hasBoat = True + player.workers = 2 + player.money = 1000 + stats = Stats() + + # call + summary = business.runDailyProduction(player, stats) + + # check - lifetime counters advance alongside the day's summary + assert stats.totalFishCaughtByCrew == summary["fishCaught"] + assert stats.totalWagesPaid == summary["wagesPaid"] + assert stats.daysInBusiness == 1 + + # a second day accumulates rather than resets + business.runDailyProduction(player, stats) + assert stats.daysInBusiness == 2 diff --git a/tests/location/test_docks.py b/tests/location/test_docks.py index 6e17f05..25fdb86 100644 --- a/tests/location/test_docks.py +++ b/tests/location/test_docks.py @@ -358,8 +358,9 @@ def test_manageBusiness_buy_boat(): 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"]) + # "1" = Buy a Boat; then in the post-purchase menu (Hire/Upgrade/Rename/Back) + # "4" = Back + docksInstance.userInterface.showOptions = MagicMock(side_effect=["1", "4"]) # call docksInstance.manageBusiness() @@ -391,8 +392,9 @@ def test_manageBusiness_hire_worker(): 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"]) + # "1" = Hire; then in the menu with a worker + # (Hire/Dismiss/Upgrade/Rename/Back) "5" = Back + docksInstance.userInterface.showOptions = MagicMock(side_effect=["1", "5"]) # call docksInstance.manageBusiness() @@ -406,11 +408,118 @@ def test_manageBusiness_dismiss_worker(): 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"]) + # "2" = Dismiss (Hire/Dismiss/Upgrade/Rename/Back); then "5" = Back + docksInstance.userInterface.showOptions = MagicMock(side_effect=["2", "5"]) # call docksInstance.manageBusiness() # check assert docksInstance.player.workers == 1 + + +def test_manageBusiness_hire_worker_increments_lifetime_stat(): + # prepare - own a boat, no crew yet + docksInstance = createDocks() + docksInstance.player.hasBoat = True + docksInstance.player.workers = 0 + docksInstance.userInterface.showOptions = MagicMock(side_effect=["1", "5"]) + + # call + docksInstance.manageBusiness() + + # check - hiring is tracked as a lifetime business stat too + assert docksInstance.stats.totalWorkersHired == 1 + + +def test_manageBusiness_upgrade_boat(): + # prepare - own a Rowboat (tier 1), enough money to upgrade to a Trawler + from src.business import business + + docksInstance = createDocks() + docksInstance.player.hasBoat = True + docksInstance.player.boatTier = 1 + trawler = business.tierInfo(2) + docksInstance.player.money = trawler["cost"] + 50 + # 0 workers under tier-1 capacity, so the menu is + # (Hire/Upgrade/Rename/Back): "2" = Upgrade, "4" = Back + docksInstance.userInterface.showOptions = MagicMock(side_effect=["2", "4"]) + + # call + docksInstance.manageBusiness() + + # check + assert docksInstance.player.boatTier == 2 + assert docksInstance.player.money == 50 + + +def test_manageBusiness_upgrade_boat_insufficient_funds(): + # prepare - can't afford the next tier + from src.business import business + + docksInstance = createDocks() + docksInstance.player.hasBoat = True + docksInstance.player.boatTier = 1 + docksInstance.player.money = business.tierInfo(2)["cost"] - 1 + # (Hire/Upgrade/Rename/Back): "2" = Upgrade, "4" = Back + docksInstance.userInterface.showOptions = MagicMock(side_effect=["2", "4"]) + + # call + docksInstance.manageBusiness() + + # check - tier and money are unchanged + assert docksInstance.player.boatTier == 1 + assert docksInstance.player.money == business.tierInfo(2)["cost"] - 1 + + +def test_manageBusiness_upgrade_boat_unavailable_at_max_tier(): + # prepare - already at the highest boat tier + from src.business import business + + docksInstance = createDocks() + docksInstance.player.hasBoat = True + docksInstance.player.boatTier = len(business.BOAT_TIERS) + # No upgrade offered at max tier, so the menu is (Hire/Rename/Back) + docksInstance.userInterface.showOptions = MagicMock(side_effect=["3"]) + + # call + docksInstance.manageBusiness() + + # check - "Back" (option 3) exits cleanly; no upgrade option was ever shown + options_shown = docksInstance.userInterface.showOptions.call_args[0][1] + assert not any("Upgrade" in option for option in options_shown) + + +def test_manageBusiness_rename(): + # prepare - own a boat, no crew, rename then back + docksInstance = createDocks() + docksInstance.player.hasBoat = True + docksInstance.player.boatTier = 1 + # (Hire/Upgrade/Rename/Back): "3" = Rename, "4" = Back + docksInstance.userInterface.showOptions = MagicMock(side_effect=["3", "4"]) + docksInstance.userInterface.promptForText = MagicMock( + return_value=" Salty Sea Co. " + ) + + # call + docksInstance.manageBusiness() + + # check - the name is trimmed + assert docksInstance.player.businessName == "Salty Sea Co." + + +def test_manageBusiness_rename_blank_keeps_previous_name(): + # prepare + docksInstance = createDocks() + docksInstance.player.hasBoat = True + docksInstance.player.boatTier = 1 + docksInstance.player.businessName = "Original Name" + # (Hire/Upgrade/Rename/Back): "3" = Rename, "4" = Back + docksInstance.userInterface.showOptions = MagicMock(side_effect=["3", "4"]) + docksInstance.userInterface.promptForText = MagicMock(return_value=" ") + + # call + docksInstance.manageBusiness() + + # check - a blank entry doesn't clear the existing name + assert docksInstance.player.businessName == "Original Name" diff --git a/tests/player/test_playerJsonReaderWriter.py b/tests/player/test_playerJsonReaderWriter.py index 3aa4acf..4ea3f0d 100644 --- a/tests/player/test_playerJsonReaderWriter.py +++ b/tests/player/test_playerJsonReaderWriter.py @@ -207,6 +207,25 @@ def test_business_fields_round_trip(): assert restored.workers == 3 +def test_boatTier_and_businessName_round_trip(): + # prepare + playerJsonReaderWriter = createPlayerJsonReaderWriter() + player = Player() + player.hasBoat = True + player.boatTier = 2 + player.businessName = "Salty Sea Co." + + # call + playerJson = playerJsonReaderWriter.createJsonFromPlayer(player) + restored = playerJsonReaderWriter.createPlayerFromJson(playerJson) + + # check + assert playerJson["boatTier"] == 2 + assert playerJson["businessName"] == "Salty Sea Co." + assert restored.boatTier == 2 + assert restored.businessName == "Salty Sea Co." + + def test_createPlayerFromJson_missingBusinessFields_defaults(): # prepare - an older save with no boat/workers fields playerJsonReaderWriter = createPlayerJsonReaderWriter() @@ -225,6 +244,8 @@ def test_createPlayerFromJson_missingBusinessFields_defaults(): # check - backward-compatible defaults assert player.hasBoat is False assert player.workers == 0 + assert player.boatTier == 0 + assert player.businessName == "" def test_createPlayerFromJson_missingFishByType_defaultsToEmpty(): diff --git a/tests/stats/test_statsJsonReaderWriter.py b/tests/stats/test_statsJsonReaderWriter.py index 4603217..dd4ee1f 100644 --- a/tests/stats/test_statsJsonReaderWriter.py +++ b/tests/stats/test_statsJsonReaderWriter.py @@ -96,6 +96,48 @@ def test_createStatsFromJson_missingEarnedMilestones_defaultsToEmpty(): assert stats.earnedMilestones == [] +def test_businessStats_round_trip(): + # prepare + statsJsonReaderWriter = createStatsJsonReaderWriter() + stats = createStats() + stats.totalWorkersHired = 4 + stats.totalFishCaughtByCrew = 120 + stats.totalWagesPaid = 300 + stats.daysInBusiness = 15 + + # call - serialize then deserialize + statsJson = statsJsonReaderWriter.createJsonFromStats(stats) + restored = statsJsonReaderWriter.createStatsFromJson(statsJson) + + # check + assert statsJson["totalWorkersHired"] == 4 + assert statsJson["totalFishCaughtByCrew"] == 120 + assert statsJson["totalWagesPaid"] == 300 + assert statsJson["daysInBusiness"] == 15 + assert restored.totalWorkersHired == 4 + assert restored.totalFishCaughtByCrew == 120 + assert restored.totalWagesPaid == 300 + assert restored.daysInBusiness == 15 + + +def test_createStatsFromJson_missingBusinessStats_defaultsToZero(): + # prepare - an older save with no business-stat fields + statsJsonReaderWriter = createStatsJsonReaderWriter() + statsJson = { + "totalFishCaught": 1, + "totalMoneyMade": 1, + } + + # call + stats = statsJsonReaderWriter.createStatsFromJson(statsJson) + + # check - backward compatible defaults + assert stats.totalWorkersHired == 0 + assert stats.totalFishCaughtByCrew == 0 + assert stats.totalWagesPaid == 0 + assert stats.daysInBusiness == 0 + + def test_writeStatsToFile(): # prepare import tempfile From 9ed149f1540ca6a72bcf249b22d7d2d427e4a668 Mon Sep 17 00:00:00 2001 From: Daniel McCoy Stephenson Date: Sat, 4 Jul 2026 13:21:41 -0600 Subject: [PATCH 2/3] Make NPC dialogue react to fishing business stage NPC.get_dialogue_response now accepts a zero-arg callable in addition to a plain string, evaluated on demand so a response can reflect live game state instead of being frozen at NPC-construction time. Sam the Dock Worker and Gilbert the Shopkeeper each get a new dialogue question whose answer is staged by the player's boat tier/crew size: no boat, boat with no crew, Rowboat crew, Trawler, and Fishing Fleet all get distinct commentary, and Gilbert calls out the crew's actual daily catch total once there is one. --- src/location/docks.py | 37 +++++++++++++++++++- src/location/shop.py | 23 +++++++++++- src/npc/npc.py | 12 +++++-- tests/location/test_docks.py | 68 ++++++++++++++++++++++++++++++++++++ tests/location/test_shop.py | 33 +++++++++++++++++ tests/npc/test_npc.py | 20 +++++++++++ 6 files changed, 189 insertions(+), 4 deletions(-) diff --git a/src/location/docks.py b/src/location/docks.py index 9d434bc..e126002 100644 --- a/src/location/docks.py +++ b/src/location/docks.py @@ -80,7 +80,11 @@ def __init__( "Fish when you have energy, sell regularly, and save your money. " "The sea has its rhythms - you'll learn them in time. " "And remember: it's not just about catching fish, it's about enjoying the life!" - } + }, + { + "question": "How's my fishing business doing?", + "response": self._businessDialogue, + }, ] ) @@ -135,6 +139,37 @@ def run(self): self.manageBusiness() return LocationType.DOCKS + def _businessDialogue(self): + """Sam's take on the player's fishing business, staged by boat tier and + crew size so it reflects real progress rather than being fixed text.""" + if not self.player.hasBoat: + return ( + "No boat yet, eh? Once you've got one, I can help you find " + "good hands to hire. A crew changes everything!" + ) + if self.player.workers == 0: + starter = business.tierInfo(business.currentTier(self.player)) + return ( + "A %s of your own! Now you just need to hire a crew to get " + "it earning." % starter["name"] + ) + tier = business.currentTier(self.player) + name = self.player.businessName or "your business" + if tier == 1: + return ( + "%s is off to a solid start with that Rowboat crew. Save up " + "and you could afford a bigger boat before long." % name + ) + if tier == 2: + return ( + "A Trawler! %s is really coming along. I've seen a lot of " + "fishermen never make it past a rowboat." % name + ) + return ( + "A whole Fishing Fleet under %s? You're the talk of the docks! " + "Never thought I'd see an outfit like that around here." % name + ) + def _businessStatus(self): if not self.player.hasBoat: starter = business.tierInfo(1) diff --git a/src/location/shop.py b/src/location/shop.py index 0526072..38076af 100644 --- a/src/location/shop.py +++ b/src/location/shop.py @@ -6,6 +6,7 @@ from ui.userInterface import UserInterface from npc.npc import NPC from fish import fish +from business import business # Upper bound on fishMultiplier so bait upgrades stop being an infinite power @@ -86,13 +87,33 @@ def __init__( "I'd say don't hoard your fish too long - sell regularly to keep money flowing. " "Use that money to buy better bait, which helps you catch more, which means more money! " "It's a beautiful cycle, really. And don't forget to save some money at the bank!" - } + }, + { + "question": "Have you noticed my crew hauling in fish?", + "response": self._crewDialogue, + }, ] ) # Daily budget for buying fish; refills when a new day begins. self.money = SHOP_DAILY_BUDGET self.lastRefillDay = self.timeService.day + def _crewDialogue(self): + """Gilbert's take on the player's fishing business, staged by whether + there's a crew hauling in fish at all and how much they bring in.""" + if not self.player.hasBoat or self.player.workers == 0: + return ( + "Can't say I have! Get yourself a boat and a crew down at the " + "docks - Sam will set you up. Then you'll really see the fish " + "pile in." + ) + fishPerDay = business.tierInfo(business.currentTier(self.player))["fishPerDay"] + dailyCatch = fishPerDay * self.player.workers + return ( + "That I have! Word is your crew hauls in about %d fish a day. " + "Keep that up and I might need a bigger vault!" % dailyCatch + ) + def run(self): li = [ "Sell Fish", diff --git a/src/npc/npc.py b/src/npc/npc.py index 07f4545..3faa855 100644 --- a/src/npc/npc.py +++ b/src/npc/npc.py @@ -17,7 +17,15 @@ def get_dialogue_options(self): return self.dialogue_options def get_dialogue_response(self, option_index: int): - """Returns the response for a specific dialogue option""" + """Returns the response for a specific dialogue option. + + A response may be a plain string, or a zero-arg callable that's + evaluated on demand - letting a response reflect current game state + (e.g. an NPC commenting on the player's fishing business) instead of + being fixed at NPC-construction time.""" if 0 <= option_index < len(self.dialogue_options): - return self.dialogue_options[option_index].get("response", "") + response = self.dialogue_options[option_index].get("response", "") + if callable(response): + return response() + return response return "" diff --git a/tests/location/test_docks.py b/tests/location/test_docks.py index 25fdb86..d8fa3dd 100644 --- a/tests/location/test_docks.py +++ b/tests/location/test_docks.py @@ -86,6 +86,74 @@ def test_talkToNPC(): assert len(call_args.get_dialogue_options()) > 0 +def test_npc_business_dialogue_staged_by_boat_ownership(): + # prepare - no boat yet + docksInstance = createDocks() + + # check + response = docksInstance._businessDialogue() + assert "No boat yet" in response + + +def test_npc_business_dialogue_staged_by_empty_crew(): + # prepare - a boat but no crew hired yet + docksInstance = createDocks() + docksInstance.player.hasBoat = True + + # check + response = docksInstance._businessDialogue() + assert "hire a crew" in response + + +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() + docksInstance.player.hasBoat = True + docksInstance.player.boatTier = tier + docksInstance.player.workers = 1 + responses[tier] = docksInstance._businessDialogue() + + # check - each tier gets distinct commentary + assert len(set(responses.values())) == 3 + assert "Fishing Fleet" in responses[3] or "fleet" in responses[3].lower() + + +def test_npc_business_dialogue_mentions_business_name(): + # prepare + docksInstance = createDocks() + docksInstance.player.hasBoat = True + docksInstance.player.workers = 1 + docksInstance.player.businessName = "Salty Dawn Fisheries" + + # check + assert "Salty Dawn Fisheries" in docksInstance._businessDialogue() + + +def test_npc_dialogue_response_reflects_business_via_callable(): + # prepare - the NPC's dialogue option resolves through the live callable, + # not a value frozen at construction time + docksInstance = createDocks() + optionIndex = next( + i + for i, option in enumerate(docksInstance.npc.get_dialogue_options()) + if option["question"] == "How's my fishing business doing?" + ) + before = docksInstance.npc.get_dialogue_response(optionIndex) + + # call - buy a boat, then ask again + docksInstance.player.hasBoat = True + + # check + after = docksInstance.npc.get_dialogue_response(optionIndex) + assert before != after + assert "No boat yet" in before + assert "hire a crew" in after + + def test_run_go_to_shop_action(): # prepare docksInstance = createDocks() diff --git a/tests/location/test_shop.py b/tests/location/test_shop.py index 60b1d7a..f6cb188 100644 --- a/tests/location/test_shop.py +++ b/tests/location/test_shop.py @@ -114,6 +114,39 @@ def test_talkToNPC(): assert len(call_args.get_dialogue_options()) > 0 +def test_npc_crew_dialogue_no_business(): + # prepare - no boat + shopInstance = createShop() + + # check + assert "Can't say I have" in shopInstance._crewDialogue() + + +def test_npc_crew_dialogue_no_workers(): + # prepare - a boat but no crew hired + shopInstance = createShop() + shopInstance.player.hasBoat = True + + # check + assert "Can't say I have" in shopInstance._crewDialogue() + + +def test_npc_crew_dialogue_reports_daily_catch(): + # prepare + from src.business import business + + shopInstance = createShop() + shopInstance.player.hasBoat = True + shopInstance.player.boatTier = 2 + shopInstance.player.workers = 3 + + # check - the reported total matches tier fishPerDay * worker count + expected = business.tierInfo(2)["fishPerDay"] * 3 + response = shopInstance._crewDialogue() + assert str(expected) in response + assert "That I have" in response + + def test_sellFish(): # prepare shopInstance = createShop() diff --git a/tests/npc/test_npc.py b/tests/npc/test_npc.py index 1075e1e..e3e6e60 100644 --- a/tests/npc/test_npc.py +++ b/tests/npc/test_npc.py @@ -88,6 +88,26 @@ def test_get_dialogue_response_invalid_index(): assert response_too_large == "" +def test_get_dialogue_response_evaluates_callable(): + # prepare - a response computed on demand, e.g. to reflect current game state + calls = [] + + def dynamicResponse(): + calls.append(1) + return "Dynamic answer #%d" % len(calls) + + dialogue_options = [{"question": "How's it going?", "response": dynamicResponse}] + npc = NPC("Guide", "A helpful guide.", dialogue_options) + + # call - each call re-evaluates the callable rather than caching a value + first = npc.get_dialogue_response(0) + second = npc.get_dialogue_response(0) + + # check + assert first == "Dynamic answer #1" + assert second == "Dynamic answer #2" + + def test_npc_without_dialogue_options(): # call npc = NPC("Simple NPC", "Just a simple character.") From 4b98c4ff673fab05255f413e58dd6f51391aaa06 Mon Sep 17 00:00:00 2001 From: Daniel McCoy Stephenson Date: Sat, 4 Jul 2026 13:51:01 -0600 Subject: [PATCH 3/3] Extend business-aware dialogue to Margaret and Old Tom Rounds out the staged NPC dialogue added for Sam and Gilbert: Margaret the Teller now comments on the business through a savings/interest lens (no boat -> no crew -> lifetime wages paid -> fleet-tier "retire wealthy" line), and Old Tom the Barkeep gives tier-based barroom banter, naming the business once it's named. Both use the same NPC.get_dialogue_response callable support introduced for the docks/shop dialogue. --- src/location/bank.py | 34 +++++++++++++++++++++++++++- src/location/tavern.py | 32 +++++++++++++++++++++++++- tests/location/test_bank.py | 42 +++++++++++++++++++++++++++++++++++ tests/location/test_tavern.py | 33 +++++++++++++++++++++++++++ 4 files changed, 139 insertions(+), 2 deletions(-) diff --git a/src/location/bank.py b/src/location/bank.py index f44935d..b5ec8be 100644 --- a/src/location/bank.py +++ b/src/location/bank.py @@ -5,6 +5,7 @@ from stats.stats import Stats from ui.userInterface import UserInterface from npc.npc import NPC +from business import business # @author Daniel McCoy Stephenson @@ -66,10 +67,41 @@ def __init__( "Invest in good bait to improve your catches, but save the profits. " "And remember: it's not about how much you earn, it's about how much you keep. " "That's the secret to real wealth!" - } + }, + { + "question": "What do you think of my fishing business?", + "response": self._businessDialogue, + }, ] ) + def _businessDialogue(self): + """Margaret's savings-minded take on the player's fishing business, + staged by boat ownership/crew size.""" + if not self.player.hasBoat: + return ( + "A boat's a fine goal to save toward! Set some money aside " + "here and it'll be waiting for you when you're ready to buy one." + ) + if self.player.workers == 0: + return ( + "A boat with no crew is just a rowboat and a dream! Bank your " + "catch money for a while and you'll have their wages covered " + "in no time." + ) + if business.currentTier(self.player) >= 3: + return ( + "A whole Fishing Fleet, paying out $%d in wages so far? " + "You could retire a wealthy soul if you keep banking those " + "profits!" % self.stats.totalWagesPaid + ) + return ( + "Your crew's brought in $%d in wages paid out so far - quite the " + "little enterprise! Don't let it all burn a hole in your pocket, " + "park some of it here and let it earn interest." + % self.stats.totalWagesPaid + ) + def run(self): li = ["Make a Deposit", "Make a Withdrawal", "Talk to %s" % self.npc.name, "Go to docks"] input = self.userInterface.showOptions( diff --git a/src/location/tavern.py b/src/location/tavern.py index 541fcfa..b02a49d 100644 --- a/src/location/tavern.py +++ b/src/location/tavern.py @@ -10,6 +10,7 @@ from stats.stats import Stats from ui.userInterface import UserInterface from npc.npc import NPC +from business import business # Dice has 6 equally likely faces, so a correct guess pays 5x the bet to make @@ -81,10 +82,39 @@ def __init__( "Start small, fish when you have energy, and sell your catch regularly. " "Don't gamble away all your coin - save some at the bank. " "And remember, better bait means better catches. Take your time and enjoy the village!" - } + }, + { + "question": "What do you make of my fishing business?", + "response": self._businessDialogue, + }, ] ) + def _businessDialogue(self): + """Old Tom's barroom banter about the player's fishing business, + staged by boat tier.""" + if not self.player.hasBoat: + return ( + "No boat yet? Can't rightly call yourself a fisherman around " + "here without a crew behind you. Get one down at the docks!" + ) + tier = business.currentTier(self.player) + name = self.player.businessName or "your outfit" + if tier == 1: + return ( + "A little Rowboat crew, eh? Everybody's gotta start somewhere. " + "Buy me a drink when you make your first real money!" + ) + if tier == 2: + return ( + "A Trawler! %s must be doing alright for itself. I might just " + "have to start calling you 'boss' around here." % name + ) + return ( + "A whole Fishing Fleet? %s is the toast of the village! Drinks " + "are on you tonight, right?" % name + ) + def run(self): li = ["Get drunk ( $10 )", "Gamble", "Talk to %s" % self.npc.name, "Go to Docks"] input = self.userInterface.showOptions( diff --git a/tests/location/test_bank.py b/tests/location/test_bank.py index 5a7ac24..656d699 100644 --- a/tests/location/test_bank.py +++ b/tests/location/test_bank.py @@ -132,6 +132,48 @@ def test_talkToNPC(): assert len(call_args.get_dialogue_options()) > 0 +def test_npc_business_dialogue_no_boat(): + # prepare + bankInstance = createBank() + + # check + assert "save toward" in bankInstance._businessDialogue() + + +def test_npc_business_dialogue_no_crew(): + # prepare + bankInstance = createBank() + bankInstance.player.hasBoat = True + + # check + assert "rowboat and a dream" in bankInstance._businessDialogue() + + +def test_npc_business_dialogue_reports_wages_paid(): + # prepare + bankInstance = createBank() + bankInstance.player.hasBoat = True + bankInstance.player.workers = 2 + bankInstance.stats.totalWagesPaid = 240 + + # check + assert "$240" in bankInstance._businessDialogue() + + +def test_npc_business_dialogue_fleet_tier(): + # prepare + from src.business import business + + bankInstance = createBank() + bankInstance.player.hasBoat = True + bankInstance.player.boatTier = len(business.BOAT_TIERS) + bankInstance.player.workers = 2 + bankInstance.stats.totalWagesPaid = 500 + + # check + assert "retire a wealthy soul" in bankInstance._businessDialogue() + + def test_deposit_success(): # prepare bankInstance = createBank() diff --git a/tests/location/test_tavern.py b/tests/location/test_tavern.py index f68ecc1..39b05cf 100644 --- a/tests/location/test_tavern.py +++ b/tests/location/test_tavern.py @@ -117,6 +117,39 @@ def test_talkToNPC(): assert len(call_args.get_dialogue_options()) > 0 +def test_npc_business_dialogue_no_boat(): + # prepare + tavernInstance = createTavern() + + # check + assert "No boat yet" in tavernInstance._businessDialogue() + + +def test_npc_business_dialogue_staged_by_tier(): + # prepare - one crewed boat per tier + responses = {} + for tier in (1, 2, 3): + tavernInstance = createTavern() + tavernInstance.player.hasBoat = True + tavernInstance.player.boatTier = tier + responses[tier] = tavernInstance._businessDialogue() + + # check - each tier gets distinct banter + assert len(set(responses.values())) == 3 + assert "Fishing Fleet" in responses[3] + + +def test_npc_business_dialogue_mentions_business_name(): + # prepare + tavernInstance = createTavern() + tavernInstance.player.hasBoat = True + tavernInstance.player.boatTier = 3 + tavernInstance.player.businessName = "Salty Dawn Fisheries" + + # check + assert "Salty Dawn Fisheries" in tavernInstance._businessDialogue() + + def test_getDrunk(): # prepare tavernInstance = createTavern()