From 904dc60512273989205a7d13dbdb589c6ea62b29 Mon Sep 17 00:00:00 2001 From: Daniel McCoy Stephenson Date: Mon, 3 Aug 2026 02:14:30 -0600 Subject: [PATCH] Reject non-positive bank amounts and fix stale crew/interest dialogue - Bank deposit/withdraw now reject amounts <= 0, closing the exploit where a negative deposit minted cash and wrote a save the game would refuse to load on the next launch. - The bank teller's interest-rate dialogue now states the actual capped rule (rate and daily cap) instead of the uncapped "the more you save, the more you earn" line. - A crew NPC's "berths full" dialogue and its unlock condition now use fleet-wide totalCrewBerths() instead of the best hull's maxWorkers, so it no longer tells a player to buy a bigger boat while another boat in their fleet still has open berths. Closes #149, #152, #158 Co-Authored-By: Claude Sonnet 5 --- src/location/bank.py | 35 ++++++++++++++++---- src/npc/villagers.py | 10 +++--- tests/location/test_bank.py | 64 +++++++++++++++++++++++++++++++++++++ tests/npc/test_villagers.py | 19 +++++++++++ 4 files changed, 115 insertions(+), 13 deletions(-) diff --git a/src/location/bank.py b/src/location/bank.py index a49fe7b..84d65c9 100644 --- a/src/location/bank.py +++ b/src/location/bank.py @@ -1,7 +1,7 @@ from location.enum.locationType import LocationType from player.player import Player from prompt.prompt import Prompt -from world.timeService import TimeService +from world.timeService import TimeService, INTEREST_RATE, MAX_INTEREST_PER_DAY from stats.stats import Stats from ui.userInterface import UserInterface from npc.npc import NPC @@ -9,6 +9,11 @@ from investments import investments from progression import progression +# The break-even point past which the daily interest cap binds - used so the +# teller's dialogue can state the real rule instead of drifting from it (see +# src/world/timeService.py for the underlying constants). +_INTEREST_CAP_BALANCE = int(MAX_INTEREST_PER_DAY / INTEREST_RATE) + # @author Daniel McCoy Stephenson class Bank: @@ -42,16 +47,20 @@ def __init__( "response": "The bank is simple and safe! You can deposit money when you have some on hand, " "and withdraw it whenever you need. We keep your money secure - " "no risk of losing it to gambling or spending it accidentally! " - "Plus, your savings earn interest over time. The more you save, the more you earn. " - "It's the smart way to grow your wealth!", + "Plus, your savings earn interest over time - up to $%d a day. " + "It's the smart way to grow your wealth!" % MAX_INTEREST_PER_DAY, }, { "question": "Tell me about interest rates.", - "response": "Ah yes, interest! Every day that passes, your savings grow by a small percentage. " - "It might not seem like much at first, but over time it really adds up! " + "response": "Ah yes, interest! Every day that passes, your savings grow by %d%%, " + "up to $%d a day - past about $%d banked you've maxed out what I can pay you. " "The interest is automatically added to your bank account. " - "Think of it as the bank paying you for keeping your money with us. " - "The more you save, the more interest you earn!", + "Think of it as the bank paying you for keeping your money with us." + % ( + int(INTEREST_RATE * 100), + MAX_INTEREST_PER_DAY, + _INTEREST_CAP_BALANCE, + ), }, { "question": "Should I save or spend my money?", @@ -222,6 +231,12 @@ def deposit(self): if amount is None: self.currentPrompt.text = "Try again. Money: $%.2f" % self.player.money continue + if amount <= 0: + self.currentPrompt.text = ( + "Enter an amount greater than zero. Money: $%.2f" + % self.player.money + ) + continue if self.player.canAfford(amount): self.player.moneyInBank += amount @@ -240,6 +255,12 @@ def withdraw(self): "Try again. Money In Bank: $%.2f" % self.player.moneyInBank ) continue + if amount <= 0: + self.currentPrompt.text = ( + "Enter an amount greater than zero. Money In Bank: $%.2f" + % self.player.moneyInBank + ) + continue if amount <= self.player.moneyInBank: self.player.money += amount diff --git a/src/npc/villagers.py b/src/npc/villagers.py index 5cb20b5..f28a9d0 100644 --- a/src/npc/villagers.py +++ b/src/npc/villagers.py @@ -212,11 +212,10 @@ def wageDialogue(): ) def crowdedDialogue(): - info = business.tierInfo(business.currentTier(player)) return ( - "All %d berths full on the %s. Elbow to elbow out there! If you " - "want more hands you'll need more boat." - % (info["maxWorkers"], info["name"]) + "All %d berths full across the fleet. Elbow to elbow out there! " + "If you want more hands you'll need another boat." + % boats.totalCrewBerths(player) ) def businessNameDialogue(): @@ -257,8 +256,7 @@ def ambitionDialogue(): # Only worth asking once there's no room left to hire. "question": "Getting crowded out there, isn't it?", "response": crowdedDialogue, - "condition": lambda: player.workers - >= business.tierInfo(business.currentTier(player))["maxWorkers"], + "condition": lambda: player.workers >= boats.totalCrewBerths(player), }, { # The outfit has to have a name before anyone can have an diff --git a/tests/location/test_bank.py b/tests/location/test_bank.py index 6c6807e..b6607bf 100644 --- a/tests/location/test_bank.py +++ b/tests/location/test_bank.py @@ -194,6 +194,26 @@ def test_npc_business_dialogue_fleet_tier(): assert "retire a wealthy soul" in bankInstance._businessDialogue() +def test_npc_interest_dialogue_discloses_the_daily_cap(): + # prepare - the teller's "interest rates" answer must state the actual + # rule (the daily cap timeService.py enforces), not just "the more you + # save, the more you earn" + from src.world.timeService import INTEREST_RATE, MAX_INTEREST_PER_DAY + + bankInstance = createBank() + options = bankInstance.npc.get_dialogue_options() + questions = [option["question"] for option in options] + index = questions.index("Tell me about interest rates.") + + # call + response = bankInstance.npc.get_dialogue_response(index) + + # check + assert "%d%%" % int(INTEREST_RATE * 100) in response + assert "$%d" % MAX_INTEREST_PER_DAY in response + assert "maxed out" in response + + def test_deposit_success(): # prepare bankInstance = createBank() @@ -333,6 +353,50 @@ def test_withdraw_invalid_input_retries_then_succeeds(): assert bankInstance.player.money == 10 +def test_deposit_negative_amount_is_rejected(): + # prepare - a negative "deposit" would otherwise pass canAfford (money >= + # negative is always true) and mint cash out of nothing + bankInstance = createBank() + bankInstance.userInterface.lotsOfSpace = MagicMock() + bankInstance.userInterface.divider = MagicMock() + bankInstance.player.money = 20 + bankInstance.player.moneyInBank = 0 + bankInstance.userInterface.promptForNumber = MagicMock( + side_effect=[-100.0, 10.0] + ) + + # call + bankInstance.deposit() + + # check - the negative attempt is rejected and re-prompted, then the + # valid second attempt goes through + assert bankInstance.userInterface.promptForNumber.call_count == 2 + assert bankInstance.player.money == 10 + assert bankInstance.player.moneyInBank == 10 + + +def test_withdraw_negative_amount_is_rejected(): + # prepare - a negative "withdrawal" would otherwise pass the + # amount <= moneyInBank check and drive player.money negative + bankInstance = createBank() + bankInstance.userInterface.lotsOfSpace = MagicMock() + bankInstance.userInterface.divider = MagicMock() + bankInstance.player.moneyInBank = 400 + bankInstance.player.money = 20 + bankInstance.userInterface.promptForNumber = MagicMock( + side_effect=[-500.0, 10.0] + ) + + # call + bankInstance.withdraw() + + # check - the negative attempt is rejected and re-prompted, then the + # valid second attempt goes through + assert bankInstance.userInterface.promptForNumber.call_count == 2 + assert bankInstance.player.moneyInBank == 390 + assert bankInstance.player.money == 30 + + def test_manageInvestments_buy_when_affordable(): # prepare from src.investments import investments diff --git a/tests/npc/test_villagers.py b/tests/npc/test_villagers.py index 0d9befa..4d839ab 100644 --- a/tests/npc/test_villagers.py +++ b/tests/npc/test_villagers.py @@ -256,6 +256,25 @@ def test_createCrewNPC_crowded_response_quotes_the_berth_count(): assert "All %d berths" % business.tierInfo(1)["maxWorkers"] in response +def test_createCrewNPC_crowded_question_stays_hidden_with_room_on_another_boat(): + # prepare - two Rowboats, full crew on the first but the second still has + # room, so hiring is fleet-wide and the player isn't actually crowded out + player = createEmployedPlayer(tier=1) + boats.addBoat(player, 1) + for villager in villagers.availableVillagers(player)[ + : business.tierInfo(1)["maxWorkers"] - 1 + ]: + boats.hireWorker(player, villager["name"]) + npc = villagers.createCrewNPC(player, "Marta Kell") + + # call + questions = [option["question"] for option in npc.get_dialogue_options()] + + # check + assert "Getting crowded out there, isn't it?" not in questions + assert player.workers < boats.totalCrewBerths(player) + + def test_createCrewNPC_ambition_response_mentions_the_business(): # prepare player = createEmployedPlayer(tier=len(business.BOAT_TIERS))