diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..4dd209b --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,36 @@ +name: Run Unit Tests + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pytest pytest-cov jsonschema + + - name: Run tests with coverage + run: | + python -m pytest --verbose -vv --cov=src --cov-report=term-missing --cov-report=xml:cov.xml + + - name: Upload coverage reports + uses: codecov/codecov-action@v4 + with: + file: ./cov.xml + fail_ci_if_error: false + continue-on-error: true diff --git a/.gitignore b/.gitignore index 4824920..aac6909 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ *.pyc -data/*.json +__pycache__/ +data/ .coverage cov.xml \ No newline at end of file diff --git a/README.md b/README.md index 2976941..e5bb4c1 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,17 @@ # FishE -This game allows you to explore a fishing village and perform actions in it. - -## UI Types - -FishE now supports two different user interface types: - -### Console UI (Default) -Traditional text-based interface that runs in the terminal. -```bash -python3 src/fishE.py -# or explicitly -python3 src/fishE.py --ui console -``` +[![Run Unit Tests](https://github.com/Stephenson-Software/FishE/actions/workflows/test.yml/badge.svg)](https://github.com/Stephenson-Software/FishE/actions/workflows/test.yml) -### Pygame UI -Windowed interface with graphics and visual styling. -```bash -python3 src/fishE.py --ui pygame -``` +This game allows you to explore a fishing village and perform actions in it. ## Features -Both interfaces provide identical game functionality: -- Fish at the docks to catch fish and earn money -- Visit the shop to sell fish and buy better bait -- Go to the bank to deposit/withdraw money -- Relax at the tavern (get drunk or gamble) -- View your stats at home -- Save/load game progress automatically - -## Requirements - -- Python 3.x -- pygame (for windowed UI mode) - -Install pygame: `pip install pygame` +### 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: -## Development +- **Existing Saves**: View all your saved games with their progress (Day, Money, Fish count, Last Modified) +- **Create New Save**: Start a fresh game in a new save slot +- **Delete Save**: Remove unwanted save files +- **Quick Load**: Load any existing save file to continue your adventure -Run tests: `./test.sh` -Run demo: `python3 demo_ui.py` +Each save file is stored in its own slot (slot_1, slot_2, etc.) in the `data/` directory, ensuring your saves never conflict with each other. diff --git a/schemas/player.json b/schemas/player.json index 30e202d..0a151e7 100644 --- a/schemas/player.json +++ b/schemas/player.json @@ -20,6 +20,11 @@ "priceForBait": { "type": "number", "minimum": 0 + }, + "energy": { + "type": "integer", + "minimum": 0, + "maximum": 100 } }, "required": [ @@ -27,6 +32,7 @@ "money", "moneyInBank", "fishMultiplier", - "priceForBait" + "priceForBait", + "energy" ] } \ No newline at end of file diff --git a/src/config/__init__.py b/src/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/config/config.py b/src/config/config.py new file mode 100644 index 0000000..cdc62c4 --- /dev/null +++ b/src/config/config.py @@ -0,0 +1,16 @@ +# @author Daniel McCoy Stephenson +class Config: + def __init__(self): + # Save file paths + self.dataDirectory = "data" + self.playerSaveFile = "data/player.json" + self.statsSaveFile = "data/stats.json" + self.timeServiceSaveFile = "data/timeService.json" + + # Initial player values + self.initialMoney = 20 + self.initialEnergy = 100 + self.initialFishCount = 0 + self.initialMoneyInBank = 0.01 + self.initialFishMultiplier = 1 + self.initialPriceForBait = 50 diff --git a/src/fishE.py b/src/fishE.py index bad8602..47855db 100644 --- a/src/fishE.py +++ b/src/fishE.py @@ -1,6 +1,5 @@ import os -import sys -import argparse +import json from location import bank, docks, home, shop, tavern from location.enum.locationType import LocationType from player.player import Player @@ -8,93 +7,82 @@ from player.playerJsonReaderWriter import PlayerJsonReaderWriter from stats.statsJsonReaderWriter import StatsJsonReaderWriter from world.timeServiceJsonReaderWriter import TimeServiceJsonReaderWriter -from location.shopJsonReaderWriter import ShopJsonReaderWriter from world.timeService import TimeService from stats.stats import Stats -from ui.userInterfaceFactory import UserInterfaceFactory -from ui.enum.uiType import UIType +from ui.userInterface import UserInterface +from saveFileManager import SaveFileManager # @author Daniel McCoy Stephenson class FishE: - def __init__(self, ui_type: UIType = UIType.CONSOLE): + def __init__(self): self.running = True - self.ui_type = ui_type self.playerJsonReaderWriter = PlayerJsonReaderWriter() self.timeServiceJsonReaderWriter = TimeServiceJsonReaderWriter() self.statsJsonReaderWriter = StatsJsonReaderWriter() - self.shopJsonReaderWriter = ShopJsonReaderWriter() + self.saveFileManager = SaveFileManager() + + # Migrate old save files to new format if they exist + self.saveFileManager.migrate_old_save_files() + + # Show save file selection menu + self._selectSaveFile() # if save file exists, load it - if ( - os.path.exists("data/player.json") - and os.path.getsize("data/player.json") > 0 - ): + player_path = self.saveFileManager.get_save_path("player.json") + if os.path.exists(player_path) and os.path.getsize(player_path) > 0: self.loadPlayer() else: self.player = Player() # if save file exists, load it - if os.path.exists("data/stats.json") and os.path.getsize("data/stats.json") > 0: + stats_path = self.saveFileManager.get_save_path("stats.json") + if os.path.exists(stats_path) and os.path.getsize(stats_path) > 0: self.loadStats() else: self.stats = Stats() # if save file exists, load it - if ( - os.path.exists("data/timeService.json") - and os.path.getsize("data/timeService.json") > 0 - ): + time_path = self.saveFileManager.get_save_path("timeService.json") + if os.path.exists(time_path) and os.path.getsize(time_path) > 0: self.loadTimeService() else: self.timeService = TimeService(self.player, self.stats) self.prompt = Prompt("What would you like to do?") - # Create user interface using factory - self.userInterface = UserInterfaceFactory.create_user_interface( - self.ui_type, self.prompt, self.timeService, self.player - ) + self.userInterface = UserInterface(self.prompt, self.timeService, self.player) - # if save file exists, load it - if ( - os.path.exists("data/shop.json") - and os.path.getsize("data/shop.json") > 0 - ): - self.loadShop() - else: - self.shop = shop.Shop( + self.locations = { + LocationType.BANK: bank.Bank( self.userInterface, self.prompt, self.player, self.stats, self.timeService, - ) - - self.locations = { - LocationType.BANK: bank.Bank( + ), + LocationType.DOCKS: docks.Docks( self.userInterface, self.prompt, self.player, self.stats, self.timeService, ), - LocationType.DOCKS: docks.Docks( + LocationType.HOME: home.Home( self.userInterface, self.prompt, self.player, self.stats, self.timeService, ), - LocationType.HOME: home.Home( + LocationType.SHOP: shop.Shop( self.userInterface, self.prompt, self.player, self.stats, self.timeService, ), - LocationType.SHOP: self.shop, LocationType.TAVERN: tavern.Tavern( self.userInterface, self.prompt, @@ -106,83 +94,166 @@ def __init__(self, ui_type: UIType = UIType.CONSOLE): self.currentLocation = LocationType.HOME + def _selectSaveFile(self): + """Display save file selection menu and let user choose""" + while True: # Use loop instead of recursion to avoid stack overflow + save_files = self.saveFileManager.list_save_files() + + print("\n" * 20) + print("-" * 75) + print("\n FISHE - SAVE FILE MANAGER") + print("-" * 75) + + if save_files: + print("\n Available Save Files:\n") + for save in save_files: + metadata = save["metadata"] + print(f" [{save['slot']}] Save Slot {save['slot']}") + print(f" Day: {metadata.get('day', 1)}") + print(f" Money: ${metadata.get('money', 0)}") + print(f" Fish: {metadata.get('fishCount', 0)}") + print(f" Last Modified: {metadata.get('last_modified', 'Unknown')}") + print() + + next_slot = self.saveFileManager.get_next_available_slot() + if next_slot is not None: + print(f" [N] Create New Save (Slot {next_slot})") + if save_files: + print(" [D] Delete a Save File") + print(" [Q] Quit") + print("-" * 75) + + choice = input("\n Select an option: ").strip().upper() + + if choice == "Q": + print("\n Goodbye!") + exit(0) + elif choice == "N" and next_slot is not None: + self.saveFileManager.select_save_slot(next_slot) + print(f"\n Creating new save in Slot {next_slot}...") + return + elif choice == "N" and next_slot is None: + print(" All save slots are full. Please delete a save first.") + elif choice == "D" and save_files: + if self._deleteSaveFile(save_files): + # Continue loop to show updated menu + continue + else: + # User cancelled, continue loop + continue + elif choice.isdigit(): + slot_num = int(choice) + if any(save["slot"] == slot_num for save in save_files): + self.saveFileManager.select_save_slot(slot_num) + print(f"\n Loading Save Slot {slot_num}...") + return + else: + print(" Invalid slot number. Try again.") + else: + print(" Invalid choice. Try again.") + + def _deleteSaveFile(self, save_files): + """Delete a save file. Returns True if a file was deleted, False if cancelled.""" + print("\n" * 20) + print("-" * 75) + print("\n DELETE SAVE FILE") + print("-" * 75) + print("\n Which save file would you like to delete?\n") + + for save in save_files: + print(f" [{save['slot']}] Save Slot {save['slot']}") + + print(" [C] Cancel") + print("-" * 75) + + while True: + choice = input("\n Select a slot to delete: ").strip().upper() + + if choice == "C": + return False + elif choice.isdigit(): + slot_num = int(choice) + if any(save["slot"] == slot_num for save in save_files): + confirm = input(f"\n Are you sure you want to delete Slot {slot_num}? (Y/N): ").strip().upper() + if confirm == "Y": + if self.saveFileManager.delete_save_slot(slot_num): + print(f"\n Slot {slot_num} deleted successfully.") + input("\n [ CONTINUE ]") + return True + else: + print(f"\n Failed to delete Slot {slot_num}.") + return False + else: + return False + else: + print(" Invalid slot number. Try again.") + else: + print(" Invalid choice. Try again.") + def play(self): - try: - while self.running: - # change location - nextLocation = self.locations[self.currentLocation].run() + while self.running: + # change location + nextLocation = self.locations[self.currentLocation].run() - if nextLocation == LocationType.NONE: - self.running = False + if nextLocation == LocationType.NONE: + self.running = False - self.currentLocation = nextLocation + self.currentLocation = nextLocation - # increase time & save - self.timeService.increaseTime() - self.save() - finally: - # Clean up UI resources - self.userInterface.cleanup() + # increase time & save + self.timeService.increaseTime() + self.save() def save(self): - # create data directory - if not os.path.exists("data"): - os.makedirs("data") - - playerSaveFile = open("data/player.json", "w") - self.playerJsonReaderWriter.writePlayerToFile(self.player, playerSaveFile) + # create data directory - use SaveFileManager's directory + if not os.path.exists(self.saveFileManager.data_directory): + os.makedirs(self.saveFileManager.data_directory, exist_ok=True) - timeServiceSaveFile = open("data/timeService.json", "w") - self.timeServiceJsonReaderWriter.writeTimeServiceToFile( - self.timeService, timeServiceSaveFile - ) + try: + with open(self.saveFileManager.get_save_path("player.json"), "w") as playerSaveFile: + self.playerJsonReaderWriter.writePlayerToFile(self.player, playerSaveFile) - statsSaveFile = open("data/stats.json", "w") - self.statsJsonReaderWriter.writeStatsToFile(self.stats, statsSaveFile) + with open(self.saveFileManager.get_save_path("timeService.json"), "w") as timeServiceSaveFile: + self.timeServiceJsonReaderWriter.writeTimeServiceToFile( + self.timeService, timeServiceSaveFile + ) - shopSaveFile = open("data/shop.json", "w") - self.shopJsonReaderWriter.writeShopToFile(self.shop, shopSaveFile) + with open(self.saveFileManager.get_save_path("stats.json"), "w") as statsSaveFile: + self.statsJsonReaderWriter.writeStatsToFile(self.stats, statsSaveFile) + except (IOError, OSError) as e: + print(f"\n Warning: Failed to save game: {e}") + # Game continues even if save fails def loadPlayer(self): - playerSaveFile = open("data/player.json", "r") - self.player = self.playerJsonReaderWriter.readPlayerFromFile(playerSaveFile) - playerSaveFile.close() + try: + with open(self.saveFileManager.get_save_path("player.json"), "r") as playerSaveFile: + self.player = self.playerJsonReaderWriter.readPlayerFromFile(playerSaveFile) + except (IOError, OSError, json.JSONDecodeError) as e: + print(f"\n Warning: Failed to load player data: {e}") + print(" Creating new player...") + self.player = Player() def loadStats(self): - statsSaveFile = open("data/stats.json", "r") - self.stats = self.statsJsonReaderWriter.readStatsFromFile(statsSaveFile) - statsSaveFile.close() + 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: + print(f"\n Warning: Failed to load stats data: {e}") + print(" Creating new stats...") + self.stats = Stats() def loadTimeService(self): - timeServiceSaveFile = open("data/timeService.json", "r") - self.timeService = self.timeServiceJsonReaderWriter.readTimeServiceFromFile( - timeServiceSaveFile, self.player, self.stats - ) - timeServiceSaveFile.close() - - def loadShop(self): - shopSaveFile = open("data/shop.json", "r") - self.shop = self.shopJsonReaderWriter.readShopFromFile( - shopSaveFile, self.userInterface, self.prompt, self.player, self.stats, self.timeService - ) - shopSaveFile.close() - - -def parse_args(): - """Parse command line arguments""" - parser = argparse.ArgumentParser(description='FishE - Text-based Fishing Game') - parser.add_argument('--ui', - choices=['console', 'pygame'], - default='console', - help='UI type to use (default: console)') - return parser.parse_args() + try: + with open(self.saveFileManager.get_save_path("timeService.json"), "r") as timeServiceSaveFile: + self.timeService = self.timeServiceJsonReaderWriter.readTimeServiceFromFile( + timeServiceSaveFile, self.player, self.stats + ) + except (IOError, OSError, json.JSONDecodeError) 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) if __name__ == "__main__": - args = parse_args() - - # Convert string to UIType enum - ui_type = UIType.CONSOLE if args.ui == 'console' else UIType.PYGAME - - fishE = FishE(ui_type) - fishE.play() + FishE = FishE() + FishE.play() diff --git a/src/location/bank.py b/src/location/bank.py index 0b455b9..17630be 100644 --- a/src/location/bank.py +++ b/src/location/bank.py @@ -1,9 +1,10 @@ -from pstats import Stats from location.enum.locationType import LocationType from player.player import Player from prompt.prompt import Prompt from world.timeService import TimeService +from stats.stats import Stats from ui.userInterface import UserInterface +from npc.npc import NPC # @author Daniel McCoy Stephenson @@ -21,9 +22,56 @@ def __init__( self.player = player self.stats = stats self.timeService = timeService + self.npc = NPC( + "Margaret the Teller", + "I've worked at this bank for fifteen years and I take pride in keeping everyone's money safe. " + "My grandmother taught me the value of saving, and I've helped many fishermen in this village " + "secure their futures. A penny saved is a penny earned, as they say!", + [ + { + "question": "Tell me about yourself.", + "response": "I've worked at this bank for fifteen years and I take pride in keeping everyone's money safe. " + "My grandmother taught me the value of saving, and I've helped many fishermen in this village " + "secure their futures. A penny saved is a penny earned, as they say!" + }, + { + "question": "How does the bank work?", + "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!" + }, + { + "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! " + "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!" + }, + { + "question": "Should I save or spend my money?", + "response": "That's the eternal question, isn't it? Here's my advice: " + "Keep some money on hand for daily needs - buying bait, paying for drinks, gambling if you must. " + "But save the rest in the bank! Your savings will grow with interest, " + "and you'll have a nice cushion for the future. " + "Many fishermen spend everything they earn and have nothing to show for it. " + "Be smarter than that!" + }, + { + "question": "What's the most important financial advice?", + "response": "Save regularly, even if it's just a little bit. Every coin counts! " + "Don't gamble away your hard-earned money - the odds are rarely in your favor. " + "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!" + } + ] + ) def run(self): - li = ["Make a Deposit", "Make a Withdrawal", "Go to docks"] + li = ["Make a Deposit", "Make a Withdrawal", "Talk to %s" % self.npc.name, "Go to docks"] input = self.userInterface.showOptions( "You're at the front of the line and the teller asks you what you want to do.", li, @@ -52,6 +100,10 @@ def run(self): return LocationType.BANK elif input == "3": + self.talkToNPC() + return LocationType.BANK + + elif input == "4": self.currentPrompt.text = "What would you like to do?" return LocationType.DOCKS @@ -63,16 +115,16 @@ def deposit(self): self.userInterface.divider() try: - amount = int(input("> ")) + amount = float(input("> ")) except ValueError: - self.currentPrompt.text = "Try again. Money: $%d" % self.player.money + self.currentPrompt.text = "Try again. Money: $%.2f" % self.player.money continue if amount <= self.player.money: self.player.moneyInBank += amount self.player.money -= amount - self.currentPrompt.text = "$%d deposited successfully." % amount + self.currentPrompt.text = "$%.2f deposited successfully." % amount else: self.currentPrompt.text = "You don't have that much money on you!" break @@ -85,10 +137,10 @@ def withdraw(self): self.userInterface.divider() try: - amount = int(input("> ")) + amount = float(input("> ")) except ValueError: self.currentPrompt.text = ( - "Try again. Money In Bank: $%d" % self.player.moneyInBank + "Try again. Money In Bank: $%.2f" % self.player.moneyInBank ) continue @@ -96,7 +148,10 @@ def withdraw(self): self.player.money += amount self.player.moneyInBank -= amount - self.currentPrompt.text = "$%d withdrawn successfully." % amount + self.currentPrompt.text = "$%.2f withdrawn successfully." % amount else: self.currentPrompt.text = "You don't have that much money in the bank!" break + + def talkToNPC(self): + self.userInterface.showInteractiveDialogue(self.npc) diff --git a/src/location/docks.py b/src/location/docks.py index ff2a428..0642b12 100644 --- a/src/location/docks.py +++ b/src/location/docks.py @@ -8,6 +8,7 @@ from world.timeService import TimeService from stats.stats import Stats from ui.userInterface import UserInterface +from npc.npc import NPC # @author Daniel McCoy Stephenson @@ -25,30 +26,86 @@ def __init__( self.player = player self.stats = stats self.timeService = timeService + self.npc = NPC( + "Sam the Dock Worker", + "Been working these docks since I was knee-high to a grasshopper. " + "My pa was a fisherman, and his pa before him. I help maintain the boats and docks, " + "and I've learned a thing or two about fishing over the years. " + "The sea provides for those who respect her!", + [ + { + "question": "Tell me about yourself.", + "response": "Been working these docks since I was knee-high to a grasshopper. " + "My pa was a fisherman, and his pa before him. I help maintain the boats and docks, " + "and I've learned a thing or two about fishing over the years. " + "The sea provides for those who respect her!" + }, + { + "question": "How do I fish at the docks?", + "response": "Fishing is what this village is all about! You need at least 10 energy to fish. " + "When you cast your line, you'll spend several random hours (1-10) fishing. " + "Each hour uses 10 energy. When a fish bites, press Enter fast - within 2 seconds! " + "Your reaction time matters. The more successful catches, the more fish you'll get. " + "Don't worry if you miss a few - you'll still catch at least one fish if you tried!" + }, + { + "question": "What other locations can I visit?", + "response": "From the docks, you can get to anywhere in the village! " + "There's your home - that's where you sleep to restore energy. " + "Gilbert's shop is where you sell fish and buy better bait. " + "The tavern is run by Old Tom - gambling and drinks there. " + "And the bank, where Margaret will keep your money safe and even give you interest!" + }, + { + "question": "Tell me about energy and rest.", + "response": "Energy is your lifeblood as a fisherman! You start each day with it, " + "and fishing uses it up - 10 energy per hour of fishing. " + "When you're running low, head home and sleep. That'll restore you for the next day. " + "The game keeps track of time - each action moves the clock forward. " + "Plan your day wisely!" + }, + { + "question": "What makes a good fisherman?", + "response": "Patience and quick reflexes! When that fish bites, you gotta be ready. " + "Invest in better bait from Gilbert - it makes a huge difference. " + "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!" + } + ] + ) def run(self): - li = ["Fish", "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"] input = self.userInterface.showOptions( "You breathe in the fresh air. Salty.", li ) if input == "1": - self.fish() - return LocationType.DOCKS + if self.player.energy >= 10: + self.fish() + return LocationType.DOCKS + else: + self.currentPrompt.text = "You're too tired to fish! Go home and sleep." + return LocationType.DOCKS elif input == "2": + self.talkToNPC() + return LocationType.DOCKS + + elif input == "3": self.currentPrompt.text = "What would you like to do?" return LocationType.HOME - elif input == "3": + elif input == "4": self.currentPrompt.text = "What would you like to do?" return LocationType.SHOP - elif input == "4": + elif input == "5": self.currentPrompt.text = "What would you like to do?" return LocationType.TAVERN - elif input == "5": + elif input == "6": self.currentPrompt.text = ( "What would you like to do? Money in Bank: $%.2f" % self.player.moneyInBank @@ -65,21 +122,71 @@ def fish(self): hours = random.randint(1, 10) + # Check if player has enough energy for all hours + energy_needed = hours * 10 + if self.player.energy < energy_needed: + # Fish for as many hours as energy allows + hours = self.player.energy // 10 + if hours == 0: + self.currentPrompt.text = "You're too tired to fish! Go home and sleep." + return + + successfulCatches = 0 + totalAttempts = 0 + for i in range(hours): print("><> ") sys.stdout.flush() time.sleep(0.5) + + # Interactive minigame: player must press Enter at the right moment + print("A fish is biting! Press Enter quickly! ") + sys.stdout.flush() + + startTime = time.time() + try: + input() + reactionTime = time.time() - startTime + + # Success if pressed within 2 seconds + if reactionTime <= 2.0: + successfulCatches += 1 + print("Got it! ") + else: + print("Too slow... ") + except (KeyboardInterrupt, EOFError): + print("Missed! ") + + sys.stdout.flush() + totalAttempts += 1 + self.stats.hoursSpentFishing += 1 self.timeService.increaseTime() + self.player.energy -= 10 # Consume 10 energy per hour - fishToAdd = random.randint(1, 10) * self.player.fishMultiplier + # Calculate fish caught based on success rate + baseFish = random.randint(1, 10) + if totalAttempts > 0: + successRate = successfulCatches / totalAttempts + fishToAdd = int(baseFish * successRate * self.player.fishMultiplier) + else: + fishToAdd = 0 + + # Ensure at least 1 fish if player attempted + if fishToAdd == 0 and totalAttempts > 0: + fishToAdd = 1 + self.player.fishCount += fishToAdd self.stats.totalFishCaught += fishToAdd if fishToAdd == 1: self.currentPrompt.text = "Nice catch!" else: - self.currentPrompt.text = "You caught %d fish! It only took %d hours!" % ( + self.currentPrompt.text = "You caught %d fish! It only took %d hours! Success rate: %d%%" % ( fishToAdd, hours, + int((successfulCatches / totalAttempts * 100) if totalAttempts > 0 else 0) ) + + def talkToNPC(self): + self.userInterface.showInteractiveDialogue(self.npc) diff --git a/src/location/home.py b/src/location/home.py index 05ebf26..145d80a 100644 --- a/src/location/home.py +++ b/src/location/home.py @@ -43,7 +43,10 @@ def run(self): def sleep(self): self.timeService.increaseDay() - self.currentPrompt.text = "You sleep until the next morning." + self.player.energy = 100 # Restore full energy when sleeping + self.currentPrompt.text = ( + "You sleep until the next morning. You feel refreshed!" + ) def displayStats(self): self.userInterface.lotsOfSpace() diff --git a/src/location/shop.py b/src/location/shop.py index 5bfeaea..5ede9cb 100644 --- a/src/location/shop.py +++ b/src/location/shop.py @@ -5,6 +5,7 @@ from world.timeService import TimeService from stats.stats import Stats from ui.userInterface import UserInterface +from npc.npc import NPC # @author Daniel McCoy Stephenson @@ -22,16 +23,60 @@ def __init__( self.player = player self.stats = stats self.timeService = timeService - self.money = 1000 # Shop starts with $1000 + self.npc = NPC( + "Gilbert the Shopkeeper", + "I've been running this shop for thirty years, ever since I inherited it from my father. " + "I've seen many fishermen come and go, but the best ones always come back for quality bait. " + "I may not fish much anymore, but I know good gear when I see it!", + [ + { + "question": "Tell me about yourself.", + "response": "I've been running this shop for thirty years, ever since I inherited it from my father. " + "I've seen many fishermen come and go, but the best ones always come back for quality bait. " + "I may not fish much anymore, but I know good gear when I see it!" + }, + { + "question": "What do you sell here?", + "response": "I deal in all things fishing! I'll buy any fish you catch - the price varies, " + "but you can expect $3 to $5 per fish. I also sell better bait that'll help you catch more fish. " + "The price goes up each time you upgrade, but trust me, it's worth it! " + "Better bait means more fish, and more fish means more money!" + }, + { + "question": "How does fishing work?", + "response": "Ah, fishing! Head down to the docks when you've got some energy. " + "You'll spend a few hours out there, and each hour costs 10 energy. " + "When a fish bites, you need to press Enter quickly - within 2 seconds! " + "Your success rate determines how many fish you catch. " + "Better bait from my shop will multiply your catch!" + }, + { + "question": "Tell me about the bait upgrades.", + "response": "Starting bait is decent, but my premium bait? That's where the magic happens! " + "Each upgrade increases your fish multiplier by 1. So if you normally catch 5 fish, " + "with a 2x multiplier you'll catch 10! The bait gets more expensive each time - " + "starts at one price then increases by 25% with each purchase. " + "But serious fishermen know it's the best investment you can make!" + }, + { + "question": "Any tips for selling fish?", + "response": "Well, the price per fish is random between $3 and $5, so sometimes you get lucky! " + "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!" + } + ] + ) def run(self): li = [ "Sell Fish", "Buy Better Bait ( $%d )" % self.player.priceForBait, + "Talk to %s" % self.npc.name, "Go to Docks", ] input = self.userInterface.showOptions( - "The shopkeeper winks at you as you behold his collection of fishing poles. Shop Money: $%d" % self.money, + "The shopkeeper winks at you as you behold his collection of fishing poles.", li, ) @@ -42,23 +87,16 @@ def run(self): self.buyBetterBait() return LocationType.SHOP elif input == "3": + self.talkToNPC() + return LocationType.SHOP + elif input == "4": self.currentPrompt.text = "What would you like to do?" return LocationType.DOCKS def sellFish(self): - if self.player.fishCount == 0: - self.currentPrompt.text = "You don't have any fish to sell!" - return - moneyToAdd = self.player.fishCount * random.randint(3, 5) - - if self.money < moneyToAdd: - self.currentPrompt.text = "The shop doesn't have enough money to buy all your fish!" - return - self.player.money += moneyToAdd self.stats.totalMoneyMade += moneyToAdd - self.money -= moneyToAdd self.player.fishCount = 0 self.currentPrompt.text = "You sold all of your fish!" @@ -72,3 +110,6 @@ def buyBetterBait(self): self.player.priceForBait = self.player.priceForBait * 1.25 self.currentPrompt.text = "You bought some better bait!" + + def talkToNPC(self): + self.userInterface.showInteractiveDialogue(self.npc) diff --git a/src/location/tavern.py b/src/location/tavern.py index 9a32438..39f4abf 100644 --- a/src/location/tavern.py +++ b/src/location/tavern.py @@ -9,6 +9,7 @@ from world.timeService import TimeService from stats.stats import Stats from ui.userInterface import UserInterface +from npc.npc import NPC # @author Daniel McCoy Stephenson @@ -28,9 +29,52 @@ def __init__( self.timeService = timeService self.currentBet = 0 + self.npc = NPC( + "Old Tom the Barkeep", + "I sailed the seven seas for forty years before settling down here. " + "Lost my leg to a shark near the Caribbean, but I got plenty of stories to make up for it. " + "These days I pour drinks and listen to folks' troubles. Best job I ever had!", + [ + { + "question": "Tell me about yourself.", + "response": "I sailed the seven seas for forty years before settling down here. " + "Lost my leg to a shark near the Caribbean, but I got plenty of stories to make up for it. " + "These days I pour drinks and listen to folks' troubles. Best job I ever had!" + }, + { + "question": "How do I make money in this village?", + "response": "Well now, there's a few ways to fill your pockets around here! " + "The most reliable is fishing at the docks - catch some fish and sell 'em at Gilbert's shop. " + "You can also try your luck at gambling right here in the tavern, but be warned - " + "the dice don't always roll in your favor! And if you're patient, the bank offers " + "interest on your savings." + }, + { + "question": "What can I do at the tavern?", + "response": "Ah, the tavern! This is the place to unwind after a long day. " + "You can get yourself drunk for $10 - though you'll wake up at home with a headache the next day! " + "Or if you're feeling lucky, you can gamble with the dice. Place a bet, pick a number from 1 to 6, " + "and if the dice matches your choice, you'll double your money!" + }, + { + "question": "Tell me about the other villagers.", + "response": "Let me see... There's Gilbert the shopkeeper - been running that shop for thirty years. " + "He'll buy your fish and sell you better bait. Then there's Sam down at the docks, " + "knows everything about fishing. Margaret at the bank will keep your money safe. " + "All good folk, they are!" + }, + { + "question": "Any advice for a newcomer?", + "response": "Aye, I've seen many fishermen come through these doors. Here's what I tell 'em all: " + "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!" + } + ] + ) def run(self): - li = ["Get drunk ( $10 )", "Gamble", "Go to Docks"] + li = ["Get drunk ( $10 )", "Gamble", "Talk to %s" % self.npc.name, "Go to Docks"] input = self.userInterface.showOptions( "You sit at the bar, watching the barkeep clean a mug with a dirty rag.", li ) @@ -51,6 +95,10 @@ def run(self): return LocationType.TAVERN elif input == "3": + self.talkToNPC() + return LocationType.TAVERN + + elif input == "4": self.currentPrompt.text = "What would you like to do?" return LocationType.DOCKS @@ -100,12 +148,13 @@ def gamble(self): self.diceThrow = random.randint(1, 6) if input == self.diceThrow: + winAmount = self.currentBet self.player.money += self.currentBet self.stats.totalMoneyMade += self.currentBet self.currentBet = 0 self.currentPrompt.text = ( "The dice rolled a %d! You won $%d! Care to try again? Current Bet: $%d" - % (self.diceThrow, self.currentBet, self.currentBet) + % (self.diceThrow, winAmount, self.currentBet) ) continue else: @@ -139,7 +188,8 @@ def changeBet(self, prompt): try: self.amount = int(input("> ")) except ValueError: - self.deposit("Try again. Money: $%d" % self.player.money) + self.currentPrompt.text = "Try again. Money: $%d" % self.player.money + return if self.amount <= self.player.money: self.currentBet = self.amount @@ -147,8 +197,11 @@ def changeBet(self, prompt): self.currentPrompt.text = ( "What will the dice land on? Current Bet: $%d" % self.currentBet ) - self.gamble() + # Don't call self.gamble() recursively - let the main loop continue else: self.currentPrompt.text = ( "You don't have that much money on you! Money: $%d" % self.player.money ) + + def talkToNPC(self): + self.userInterface.showInteractiveDialogue(self.npc) diff --git a/src/npc/__init__.py b/src/npc/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/npc/npc.py b/src/npc/npc.py new file mode 100644 index 0000000..07f4545 --- /dev/null +++ b/src/npc/npc.py @@ -0,0 +1,23 @@ +# @author Daniel McCoy Stephenson +class NPC: + def __init__(self, name: str, backstory: str, dialogue_options: list = None): + self.name = name + self.backstory = backstory + if dialogue_options is None: + self.dialogue_options = [] + else: + self.dialogue_options = dialogue_options + + def introduce(self): + """Returns the NPC's introduction text""" + return f"{self.name}: {self.backstory}" + + def get_dialogue_options(self): + """Returns list of available dialogue options""" + return self.dialogue_options + + def get_dialogue_response(self, option_index: int): + """Returns the response for a specific dialogue option""" + if 0 <= option_index < len(self.dialogue_options): + return self.dialogue_options[option_index].get("response", "") + return "" diff --git a/src/player/player.py b/src/player/player.py index e30ef13..045af8f 100644 --- a/src/player/player.py +++ b/src/player/player.py @@ -6,3 +6,4 @@ def __init__(self): self.moneyInBank = 0.01 self.fishMultiplier = 1 self.priceForBait = 50 + self.energy = 100 diff --git a/src/player/playerJsonReaderWriter.py b/src/player/playerJsonReaderWriter.py index 89e95f1..69b7d89 100644 --- a/src/player/playerJsonReaderWriter.py +++ b/src/player/playerJsonReaderWriter.py @@ -10,6 +10,7 @@ def createJsonFromPlayer(self, player): "money": player.money, "moneyInBank": player.moneyInBank, "priceForBait": player.priceForBait, + "energy": player.energy, } def createPlayerFromJson(self, playerJson): @@ -19,6 +20,9 @@ def createPlayerFromJson(self, playerJson): player.money = playerJson["money"] player.moneyInBank = playerJson["moneyInBank"] player.priceForBait = playerJson["priceForBait"] + player.energy = playerJson.get( + "energy", 100 + ) # Default to 100 for backwards compatibility return player def writePlayerToFile(self, player, jsonFile): diff --git a/src/saveFileManager.py b/src/saveFileManager.py new file mode 100644 index 0000000..07f727c --- /dev/null +++ b/src/saveFileManager.py @@ -0,0 +1,161 @@ +import os +import json +import shutil +from datetime import datetime + + +# @author Daniel McCoy Stephenson +class SaveFileManager: + """Manages multiple save files for the game""" + + def __init__(self, data_directory="data"): + self.data_directory = data_directory + self.selected_save_slot = None + + def list_save_files(self): + """Returns a list of available save file slots with their metadata""" + if not os.path.exists(self.data_directory): + return [] + + save_files = [] + # Look for save slots (slot_1, slot_2, etc.) by inspecting existing directories + try: + for entry in os.listdir(self.data_directory): + if not entry.startswith("slot_"): + continue + + # Extract the numeric slot index from the directory name + _, _, suffix = entry.partition("_") + if not suffix.isdigit(): + continue + + slot_index = int(suffix) + if slot_index < 1 or slot_index >= 100: + # Preserve the upper bound of 99 save slots + continue + + slot_name = entry + slot_path = os.path.join(self.data_directory, slot_name) + if not os.path.isdir(slot_path): + continue + + metadata = self._read_save_metadata(slot_path) + if metadata: + save_files.append( + { + "slot": slot_index, + "slot_name": slot_name, + "path": slot_path, + "metadata": metadata, + } + ) + except OSError: + # If we can't read the directory, return empty list + return [] + + return save_files + + def _read_save_metadata(self, slot_path): + """Read metadata from a save slot""" + try: + player_file = os.path.join(slot_path, "player.json") + time_file = os.path.join(slot_path, "timeService.json") + + if not os.path.exists(player_file): + return None + + metadata = {} + + # Read player data + if os.path.exists(player_file) and os.path.getsize(player_file) > 0: + with open(player_file, "r") as f: + player_data = json.load(f) + metadata["money"] = player_data.get("money", 0) + metadata["fishCount"] = player_data.get("fishCount", 0) + metadata["energy"] = player_data.get("energy", 100) + + # Read time data + if os.path.exists(time_file) and os.path.getsize(time_file) > 0: + with open(time_file, "r") as f: + time_data = json.load(f) + metadata["day"] = time_data.get("day", 1) + metadata["time"] = time_data.get("time", 0) + + # Get last modified time + metadata["last_modified"] = datetime.fromtimestamp( + os.path.getmtime(player_file) + ).strftime("%Y-%m-%d %H:%M:%S") + + return metadata + except (json.JSONDecodeError, IOError, OSError) as e: + # Return None for corrupted or inaccessible save files + return None + + def get_next_available_slot(self): + """Returns the next available save slot number, or None if all slots are full""" + save_files = self.list_save_files() + if not save_files: + return 1 + + # Find gaps in slot numbers + existing_slots = sorted([save["slot"] for save in save_files]) + for i in range(1, 100): + if i not in existing_slots: + return i + # All 99 slots are full + return None + + def select_save_slot(self, slot_number): + """Select a save slot to use""" + self.selected_save_slot = slot_number + + def get_save_path(self, filename): + """Get the full path for a save file in the selected slot""" + if self.selected_save_slot is None: + raise ValueError("No save slot selected") + + slot_name = f"slot_{self.selected_save_slot}" + slot_path = os.path.join(self.data_directory, slot_name) + + # Create slot directory if it doesn't exist + if not os.path.exists(slot_path): + os.makedirs(slot_path, exist_ok=True) + + return os.path.join(slot_path, filename) + + def delete_save_slot(self, slot_number): + """Delete a save slot""" + slot_name = f"slot_{slot_number}" + slot_path = os.path.join(self.data_directory, slot_name) + + if os.path.exists(slot_path): + shutil.rmtree(slot_path) + return True + return False + + def migrate_old_save_files(self): + """Migrate old save files (data/*.json) to slot_1 if they exist""" + old_player = os.path.join(self.data_directory, "player.json") + old_stats = os.path.join(self.data_directory, "stats.json") + old_time = os.path.join(self.data_directory, "timeService.json") + + # Check if old save files exist + if not os.path.exists(old_player): + return False + + # Create slot_1 directory + slot_1_path = os.path.join(self.data_directory, "slot_1") + if not os.path.exists(slot_1_path): + os.makedirs(slot_1_path, exist_ok=True) + + # Move files to slot_1 + try: + if os.path.exists(old_player): + shutil.move(old_player, os.path.join(slot_1_path, "player.json")) + if os.path.exists(old_stats): + shutil.move(old_stats, os.path.join(slot_1_path, "stats.json")) + if os.path.exists(old_time): + shutil.move(old_time, os.path.join(slot_1_path, "timeService.json")) + return True + except (IOError, OSError): + return False diff --git a/src/ui/userInterface.py b/src/ui/userInterface.py index 54ca1fa..7741e21 100644 --- a/src/ui/userInterface.py +++ b/src/ui/userInterface.py @@ -1,2 +1,137 @@ -# For backward compatibility, import ConsoleUserInterface as UserInterface -from ui.consoleUserInterface import ConsoleUserInterface as UserInterface +from prompt.prompt import Prompt +from player.player import Player +from world.timeService import TimeService + + +# @author Daniel McCoy Stephenson +class UserInterface: + def __init__(self, currentPrompt: Prompt, timeService: TimeService, player: Player): + self.currentPrompt = currentPrompt + self.timeService = timeService + self.player = player + + self.prompt = "Make your choice!" + self.optionList = [] + + self.times = { + 0: "12:00 AM", + 1: "1:00 AM", + 2: "2:00 AM", + 3: "3:00 AM", + 4: "4:00 AM", + 5: "5:00 AM", + 6: "6:00 AM", + 7: "7:00 AM", + 8: "8:00 AM", + 9: "9:00 AM", + 10: "10:00 AM", + 11: "11:00 AM", + 12: "12:00 PM", + 13: "1:00 PM", + 14: "2:00 PM", + 15: "3:00 PM", + 16: "4:00 PM", + 17: "5:00 PM", + 18: "6:00 PM", + 19: "7:00 PM", + 20: "8:00 PM", + 21: "9:00 PM", + 22: "10:00 PM", + 23: "11:00 PM", + } + + def lotsOfSpace(self): + print("\n" * 20) + + def divider(self): + print("\n") + print("-" * 75) + print("\n") + + def showOptions( + self, + descriptor, + optionList, + ): + while True: + self.lotsOfSpace() + self.divider() + print(" " + descriptor) + self.divider() + print(" Day %d" % self.timeService.day) + print(" | " + self.times[self.timeService.time]) + print(" | Money: $%d" % self.player.money) + print(" | Fish: %d" % self.player.fishCount) + print(" | Energy: %d" % self.player.energy) + print("\n " + self.currentPrompt.text) + self.divider() + self.n = 1 + self.listOfN = [] + for option in optionList: + print(" [%d] %s" % (self.n, option)) + self.listOfN.append("%d" % self.n) + self.n += 1 + + choice = input("\n> ") + for i in self.listOfN: + if choice == i: + return choice + + self.currentPrompt.text = "Try again!" + + def showDialogue(self, text): + self.lotsOfSpace() + self.divider() + print(text) + self.divider() + input(" [ CONTINUE ]") + self.currentPrompt.text = "What would you like to do?" + + def showInteractiveDialogue(self, npc): + """Shows an interactive dialogue menu with the NPC""" + while True: + self.lotsOfSpace() + self.divider() + print(f" Talking with {npc.name}") + self.divider() + + # Show dialogue options + dialogue_options = npc.get_dialogue_options() + if not dialogue_options: + # Fallback to simple introduction if no options + print(npc.introduce()) + self.divider() + input(" [ CONTINUE ]") + self.currentPrompt.text = "What would you like to do?" + break + + print(" What would you like to ask?\n") + option_list = [] + for i, option in enumerate(dialogue_options): + question = option.get("question", f"Option {i+1}") + print(f" [{i+1}] {question}") + option_list.append(str(i+1)) + + print(f" [{len(option_list)+1}] [Back]") + option_list.append(str(len(option_list)+1)) + + choice = input("\n> ") + + if choice in option_list: + choice_idx = int(choice) - 1 + + # Check if user chose to go back + if choice_idx == len(dialogue_options): + self.currentPrompt.text = "What would you like to do?" + break + + # Show the response + response = npc.get_dialogue_response(choice_idx) + self.lotsOfSpace() + self.divider() + print(f" {npc.name}: {response}") + self.divider() + input(" [ CONTINUE ]") + else: + print(" Invalid choice. Try again!") + input(" [ CONTINUE ]") diff --git a/tests/config/test_config.py b/tests/config/test_config.py new file mode 100644 index 0000000..fc5d549 --- /dev/null +++ b/tests/config/test_config.py @@ -0,0 +1,24 @@ +from src.config.config import Config + + +def createConfig(): + return Config() + + +def test_initialization(): + # call + config = createConfig() + + # check save file paths + assert config.dataDirectory == "data" + assert config.playerSaveFile == "data/player.json" + assert config.statsSaveFile == "data/stats.json" + assert config.timeServiceSaveFile == "data/timeService.json" + + # check initial player values + assert config.initialMoney == 20 + assert config.initialEnergy == 100 + assert config.initialFishCount == 0 + assert config.initialMoneyInBank == 0.01 + assert config.initialFishMultiplier == 1 + assert config.initialPriceForBait == 50 diff --git a/tests/location/test_bank.py b/tests/location/test_bank.py index 6f86754..f6e4f32 100644 --- a/tests/location/test_bank.py +++ b/tests/location/test_bank.py @@ -27,6 +27,8 @@ def test_initialization(): assert bankInstance.player != None assert bankInstance.stats != None assert bankInstance.timeService != None + assert bankInstance.npc != None + assert bankInstance.npc.name == "Margaret the Teller" def test_run_make_deposit_success(): @@ -92,7 +94,7 @@ def test_run_make_withdrawal_failure_no_money(): def test_run_go_to_docks_action(): # prepare bankInstance = createBank() - bankInstance.userInterface.showOptions = MagicMock(return_value="3") + bankInstance.userInterface.showOptions = MagicMock(return_value="4") # call nextLocation = bankInstance.run() @@ -101,6 +103,35 @@ def test_run_go_to_docks_action(): assert nextLocation == LocationType.DOCKS +def test_run_talk_to_npc_action(): + # prepare + bankInstance = createBank() + bankInstance.userInterface.showOptions = MagicMock(return_value="3") + bankInstance.talkToNPC = MagicMock() + + # call + nextLocation = bankInstance.run() + + # check + assert nextLocation == LocationType.BANK + bankInstance.talkToNPC.assert_called_once() + + +def test_talkToNPC(): + # prepare + bankInstance = createBank() + bankInstance.userInterface.showInteractiveDialogue = MagicMock() + + # call + bankInstance.talkToNPC() + + # check + bankInstance.userInterface.showInteractiveDialogue.assert_called_once() + call_args = bankInstance.userInterface.showInteractiveDialogue.call_args[0][0] + assert call_args.name == "Margaret the Teller" + assert len(call_args.get_dialogue_options()) > 0 + + def test_deposit_success(): # prepare bankInstance = createBank() @@ -175,3 +206,41 @@ def test_withdraw_failure_not_enough_money(): bank.print.assert_called_once() assert bankInstance.player.moneyInBank == 5 assert bankInstance.player.money == 0 + + +def test_deposit_with_decimal(): + # prepare + bankInstance = createBank() + bankInstance.userInterface.lotsOfSpace = MagicMock() + bankInstance.userInterface.divider = MagicMock() + bankInstance.player.money = 100.50 + bankInstance.player.moneyInBank = 0 + bank.print = MagicMock() + bank.input = MagicMock(return_value="10.25") + + # call + bankInstance.deposit() + + # check + bank.print.assert_called_once() + assert bankInstance.player.moneyInBank == 10.25 + assert bankInstance.player.money == 90.25 + + +def test_withdraw_with_decimal(): + # prepare + bankInstance = createBank() + bankInstance.userInterface.lotsOfSpace = MagicMock() + bankInstance.userInterface.divider = MagicMock() + bankInstance.player.moneyInBank = 100.75 + bankInstance.player.money = 0 + bank.print = MagicMock() + bank.input = MagicMock(return_value="10.50") + + # call + bankInstance.withdraw() + + # check + bank.print.assert_called_once() + assert bankInstance.player.moneyInBank == 90.25 + assert bankInstance.player.money == 10.50 diff --git a/tests/location/test_docks.py b/tests/location/test_docks.py index a295716..28839ae 100644 --- a/tests/location/test_docks.py +++ b/tests/location/test_docks.py @@ -5,7 +5,7 @@ from src.stats.stats import Stats from src.ui.userInterface import UserInterface from src.world.timeService import TimeService -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch def createDocks(): @@ -27,6 +27,8 @@ def test_initialization(): assert docksInstance.player != None assert docksInstance.stats != None assert docksInstance.timeService != None + assert docksInstance.npc != None + assert docksInstance.npc.name == "Sam the Dock Worker" def test_run_fish_action(): @@ -46,7 +48,7 @@ def test_run_fish_action(): def test_run_go_home_action(): # prepare docksInstance = createDocks() - docksInstance.userInterface.showOptions = MagicMock(return_value="2") + docksInstance.userInterface.showOptions = MagicMock(return_value="3") # call nextLocation = docksInstance.run() @@ -55,10 +57,39 @@ def test_run_go_home_action(): assert nextLocation == LocationType.HOME +def test_run_talk_to_npc_action(): + # prepare + docksInstance = createDocks() + docksInstance.userInterface.showOptions = MagicMock(return_value="2") + docksInstance.talkToNPC = MagicMock() + + # call + nextLocation = docksInstance.run() + + # check + assert nextLocation == LocationType.DOCKS + docksInstance.talkToNPC.assert_called_once() + + +def test_talkToNPC(): + # prepare + docksInstance = createDocks() + docksInstance.userInterface.showInteractiveDialogue = MagicMock() + + # call + docksInstance.talkToNPC() + + # check + docksInstance.userInterface.showInteractiveDialogue.assert_called_once() + call_args = docksInstance.userInterface.showInteractiveDialogue.call_args[0][0] + assert call_args.name == "Sam the Dock Worker" + assert len(call_args.get_dialogue_options()) > 0 + + def test_run_go_to_shop_action(): # prepare docksInstance = createDocks() - docksInstance.userInterface.showOptions = MagicMock(return_value="3") + docksInstance.userInterface.showOptions = MagicMock(return_value="4") # call nextLocation = docksInstance.run() @@ -70,7 +101,7 @@ def test_run_go_to_shop_action(): def test_run_go_to_tavern_action(): # prepare docksInstance = createDocks() - docksInstance.userInterface.showOptions = MagicMock(return_value="4") + docksInstance.userInterface.showOptions = MagicMock(return_value="5") # call nextLocation = docksInstance.run() @@ -82,7 +113,7 @@ def test_run_go_to_tavern_action(): def test_run_go_to_bank_action(): # prepare docksInstance = createDocks() - docksInstance.userInterface.showOptions = MagicMock(return_value="5") + docksInstance.userInterface.showOptions = MagicMock(return_value="6") # call nextLocation = docksInstance.run() @@ -96,20 +127,166 @@ def test_fish(): docksInstance = createDocks() docksInstance.userInterface.lotsOfSpace = MagicMock() docksInstance.userInterface.divider = MagicMock() - docks.print = MagicMock() - docks.sys.stdout.flush = MagicMock() - docks.time.sleep = MagicMock() - docks.random.randint = MagicMock(return_value=3) - docksInstance.timeService.increaseTime = MagicMock() + + # Create a side effect that alternates between 0 and 0.5 indefinitely + def time_side_effect(): + while True: + yield 0 + yield 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.time.time', side_effect=time_side_effect()), \ + patch('src.location.docks.input', return_value=""), \ + patch('src.location.docks.random.randint', return_value=3): + + docksInstance.timeService.increaseTime = MagicMock() + + # call + docksInstance.fish() + + # check + docksInstance.userInterface.lotsOfSpace.assert_called_once() + docksInstance.userInterface.divider.assert_called_once() + # Player should catch fish based on success rate + assert docksInstance.player.fishCount >= 1 + assert docksInstance.stats.totalFishCaught >= 1 + + +def test_run_fish_action_low_energy(): + # prepare + docksInstance = createDocks() + docksInstance.player.energy = 5 # Too low to fish + docksInstance.userInterface.showOptions = MagicMock(return_value="1") # call - docksInstance.fish() + nextLocation = docksInstance.run() # check - docksInstance.userInterface.lotsOfSpace.assert_called_once() - docksInstance.userInterface.divider.assert_called_once() - assert docks.print.call_count == 4 - assert docks.sys.stdout.flush.call_count == 4 - assert docks.time.sleep.call_count == 4 - assert docksInstance.player.fishCount == 3 - assert docksInstance.stats.totalFishCaught == 3 + assert nextLocation == LocationType.DOCKS + assert ( + docksInstance.currentPrompt.text + == "You're too tired to fish! Go home and sleep." + ) + + +def test_fish_consumes_energy(): + # prepare + docksInstance = createDocks() + docksInstance.player.energy = 100 + docksInstance.userInterface.lotsOfSpace = MagicMock() + docksInstance.userInterface.divider = MagicMock() + + # Create a side effect that alternates between 0 and 0.5 indefinitely + def time_side_effect(): + while True: + yield 0 + yield 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.time.time', side_effect=time_side_effect()), \ + patch('src.location.docks.input', return_value=""), \ + patch('src.location.docks.random.randint', return_value=3): + + docksInstance.timeService.increaseTime = MagicMock() + + # call + docksInstance.fish() + + # check + assert docksInstance.player.energy == 100 - ( + 3 * 10 + ) # Should lose 30 energy (3 hours * 10 per hour) + + +def test_fish_with_limited_energy(): + # prepare + docksInstance = createDocks() + docksInstance.player.energy = 25 # Only enough for 2 hours + docksInstance.userInterface.lotsOfSpace = MagicMock() + docksInstance.userInterface.divider = MagicMock() + + # Create a side effect that alternates between 0 and 0.5 indefinitely + def time_side_effect(): + while True: + yield 0 + yield 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.time.time', side_effect=time_side_effect()), \ + patch('src.location.docks.input', return_value=""), \ + patch('src.location.docks.random.randint', return_value=5): + + docksInstance.timeService.increaseTime = MagicMock() + + # call + docksInstance.fish() + + # check + assert docksInstance.player.energy == 5 # Should be 25 - (2 * 10) + assert ( + docksInstance.timeService.increaseTime.call_count == 2 + ) # Only fished for 2 hours due to energy limit + + +def test_fish_interactive_success(): + # Test that quick reactions result in successful catches + docksInstance = createDocks() + docksInstance.userInterface.lotsOfSpace = MagicMock() + docksInstance.userInterface.divider = MagicMock() + + # Create a side effect that alternates between 0 and 0.5 indefinitely (quick reactions) + def time_side_effect(): + while True: + yield 0 + yield 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.time.time', side_effect=time_side_effect()), \ + patch('src.location.docks.input', return_value=""), \ + patch('src.location.docks.random.randint', side_effect=[3, 6]): + + docksInstance.timeService.increaseTime = MagicMock() + + # call + docksInstance.fish() + + # check - with 100% success rate, should get full catch + assert docksInstance.player.fishCount >= 3 # Should get good catch with all successes + assert docksInstance.stats.totalFishCaught >= 3 + + +def test_fish_interactive_failure(): + # Test that slow reactions result in fewer catches + docksInstance = createDocks() + docksInstance.userInterface.lotsOfSpace = MagicMock() + docksInstance.userInterface.divider = MagicMock() + + # Create a side effect that alternates between 0 and 3.0 indefinitely (slow reactions) + def time_side_effect(): + while True: + yield 0 + yield 3.0 + + with patch('src.location.docks.print'), \ + patch('src.location.docks.sys.stdout.flush'), \ + patch('src.location.docks.time.sleep'), \ + patch('src.location.docks.time.time', side_effect=time_side_effect()), \ + patch('src.location.docks.input', return_value=""), \ + patch('src.location.docks.random.randint', side_effect=[3, 10]): + + docksInstance.timeService.increaseTime = MagicMock() + + # call + docksInstance.fish() + + # check - with 0% success rate, should still get at least 1 fish minimum + assert docksInstance.player.fishCount == 1 # Minimum 1 fish even with failures + assert docksInstance.stats.totalFishCaught == 1 diff --git a/tests/location/test_home.py b/tests/location/test_home.py index 4dae402..1611581 100644 --- a/tests/location/test_home.py +++ b/tests/location/test_home.py @@ -85,13 +85,31 @@ def test_sleep(): # prepare homeInstance = createHome() homeInstance.timeService.increaseDay = MagicMock() + homeInstance.player.energy = 50 # Set energy to something less than 100 # call homeInstance.sleep() # check homeInstance.timeService.increaseDay.assert_called_once() - assert homeInstance.currentPrompt.text == "You sleep until the next morning." + assert ( + homeInstance.currentPrompt.text + == "You sleep until the next morning. You feel refreshed!" + ) + assert homeInstance.player.energy == 100 # Energy should be restored to full + + +def test_sleep_restores_energy(): + # prepare + homeInstance = createHome() + homeInstance.timeService.increaseDay = MagicMock() + homeInstance.player.energy = 10 # Low energy + + # call + homeInstance.sleep() + + # check + assert homeInstance.player.energy == 100 def test_displayStats(): diff --git a/tests/location/test_shop.py b/tests/location/test_shop.py index 520af73..2908184 100644 --- a/tests/location/test_shop.py +++ b/tests/location/test_shop.py @@ -27,7 +27,8 @@ def test_initialization(): assert shopInstance.player != None assert shopInstance.stats != None assert shopInstance.timeService != None - assert shopInstance.money == 1000 + assert shopInstance.npc != None + assert shopInstance.npc.name == "Gilbert the Shopkeeper" def test_run_sell_fish_action(): @@ -61,7 +62,7 @@ def test_run_buy_better_bait_action(): def test_run_go_to_docks_action(): # prepare shopInstance = createShop() - shopInstance.userInterface.showOptions = MagicMock(return_value="3") + shopInstance.userInterface.showOptions = MagicMock(return_value="4") # call nextLocation = shopInstance.run() @@ -70,52 +71,47 @@ def test_run_go_to_docks_action(): assert nextLocation == LocationType.DOCKS -def test_sellFish(): +def test_run_talk_to_npc_action(): # prepare shopInstance = createShop() - shopInstance.player.fishCount = 10 + shopInstance.userInterface.showOptions = MagicMock(return_value="3") + shopInstance.talkToNPC = MagicMock() # call - shopInstance.sellFish() + nextLocation = shopInstance.run() # check - assert shopInstance.player.fishCount == 0 - assert shopInstance.player.money > 0 - assert shopInstance.stats.totalMoneyMade > 0 - assert shopInstance.money < 1000 # Shop money should decrease + assert nextLocation == LocationType.SHOP + shopInstance.talkToNPC.assert_called_once() -def test_sellFish_no_fish(): +def test_talkToNPC(): # prepare shopInstance = createShop() - shopInstance.player.fishCount = 0 - initialMoney = shopInstance.money + shopInstance.userInterface.showInteractiveDialogue = MagicMock() # call - shopInstance.sellFish() + shopInstance.talkToNPC() # check - assert shopInstance.player.fishCount == 0 - assert shopInstance.money == initialMoney # Shop money should not change - assert "don't have any fish" in shopInstance.currentPrompt.text + shopInstance.userInterface.showInteractiveDialogue.assert_called_once() + call_args = shopInstance.userInterface.showInteractiveDialogue.call_args[0][0] + assert call_args.name == "Gilbert the Shopkeeper" + assert len(call_args.get_dialogue_options()) > 0 -def test_sellFish_shop_not_enough_money(): +def test_sellFish(): # prepare shopInstance = createShop() - shopInstance.player.fishCount = 100 # Lots of fish - shopInstance.money = 10 # Very little shop money - initialPlayerMoney = shopInstance.player.money - initialShopMoney = shopInstance.money + shopInstance.player.fishCount = 10 # call shopInstance.sellFish() # check - assert shopInstance.player.fishCount == 100 # Fish should not be sold - assert shopInstance.player.money == initialPlayerMoney # Player money unchanged - assert shopInstance.money == initialShopMoney # Shop money unchanged - assert "doesn't have enough money" in shopInstance.currentPrompt.text + assert shopInstance.player.fishCount == 0 + assert shopInstance.player.money > 0 + assert shopInstance.stats.totalMoneyMade > 0 def test_buyBetterBait(): diff --git a/tests/location/test_tavern.py b/tests/location/test_tavern.py index a6b6a1b..fd60448 100644 --- a/tests/location/test_tavern.py +++ b/tests/location/test_tavern.py @@ -27,6 +27,8 @@ def test_initialization(): assert tavernInstance.player != None assert tavernInstance.stats != None assert tavernInstance.timeService != None + assert tavernInstance.npc != None + assert tavernInstance.npc.name == "Old Tom the Barkeep" def test_run_get_drunk_action_success(): @@ -77,7 +79,7 @@ def test_run_gamble_action_success(): def test_run_go_to_docks_action(): # prepare tavernInstance = createTavern() - tavernInstance.userInterface.showOptions = MagicMock(return_value="3") + tavernInstance.userInterface.showOptions = MagicMock(return_value="4") # call nextLocation = tavernInstance.run() @@ -86,6 +88,35 @@ def test_run_go_to_docks_action(): assert nextLocation == LocationType.DOCKS +def test_run_talk_to_npc_action(): + # prepare + tavernInstance = createTavern() + tavernInstance.userInterface.showOptions = MagicMock(return_value="3") + tavernInstance.talkToNPC = MagicMock() + + # call + nextLocation = tavernInstance.run() + + # check + assert nextLocation == LocationType.TAVERN + tavernInstance.talkToNPC.assert_called_once() + + +def test_talkToNPC(): + # prepare + tavernInstance = createTavern() + tavernInstance.userInterface.showInteractiveDialogue = MagicMock() + + # call + tavernInstance.talkToNPC() + + # check + tavernInstance.userInterface.showInteractiveDialogue.assert_called_once() + call_args = tavernInstance.userInterface.showInteractiveDialogue.call_args[0][0] + assert call_args.name == "Old Tom the Barkeep" + assert len(call_args.get_dialogue_options()) > 0 + + def test_getDrunk(): # prepare tavernInstance = createTavern() @@ -106,71 +137,193 @@ def test_getDrunk(): tavernInstance.timeService.increaseDay.assert_called_once() -def test_getDrunk_no_money_loss(): +def test_changeBet_no_recursive_gamble(): # prepare tavernInstance = createTavern() tavernInstance.userInterface.lotsOfSpace = MagicMock() tavernInstance.userInterface.divider = MagicMock() tavernInstance.player.money = 100 - initial_money = tavernInstance.player.money - tavern.print = MagicMock() - tavern.sys.stdout.flush = MagicMock() - tavern.time.sleep = MagicMock() - tavern.random.random = MagicMock(return_value=0.5) # No money loss (> 0.3) - tavernInstance.timeService.increaseDay = MagicMock() - # call - tavernInstance.getDrunk() + # Mock gamble method to detect if it's called + tavernInstance.gamble = MagicMock() + + # Mock input to simulate user entering valid bet amount + with MagicMock() as mock_input: + mock_input.return_value = "50" + + # Temporarily replace the built-in input function + import builtins + + original_input = builtins.input + builtins.input = mock_input + + try: + # call + tavernInstance.changeBet( + "How much money would you like to bet? Money: $100" + ) + + # check + # Verify that gamble was NOT called recursively + tavernInstance.gamble.assert_not_called() + # Verify that the bet was set correctly + assert tavernInstance.currentBet == 50 + # Verify the prompt was updated correctly + assert ( + "What will the dice land on? Current Bet: $50" + in tavernInstance.currentPrompt.text + ) + finally: + # Restore original input function + builtins.input = original_input + + +def test_gamble_win_shows_correct_amount(): + # prepare + tavernInstance = createTavern() + tavernInstance.player.money = 100 + tavernInstance.currentBet = 50 + tavern.random.randint = MagicMock( + return_value=1 + ) # Make sure dice matches player choice + + # Store prompt text after the win + win_prompt_text = None + + def mock_showOptions(prompt, options): + nonlocal win_prompt_text + # First call: player chooses 1 + # After processing, capture the prompt text + if win_prompt_text is None: + result = "1" + # We need to manually process what would happen + return result + else: + # Second call: player chooses to go back + return "8" + + tavernInstance.userInterface.showOptions = MagicMock(side_effect=["1", "8"]) + + # We need to capture the state after the win but before the next iteration + # Let's test the win logic directly instead + input_value = 1 + tavernInstance.diceThrow = 1 + + # Execute the win condition logic + winAmount = tavernInstance.currentBet + tavernInstance.player.money += tavernInstance.currentBet + tavernInstance.stats.totalMoneyMade += tavernInstance.currentBet + tavernInstance.currentBet = 0 + tavernInstance.currentPrompt.text = ( + "The dice rolled a %d! You won $%d! Care to try again? Current Bet: $%d" + % (tavernInstance.diceThrow, winAmount, tavernInstance.currentBet) + ) # check - assert tavernInstance.player.money == initial_money - 10 # Only lost the $10 cost - assert tavernInstance.stats.moneyLostWhileDrunk == 0 # No additional money lost tracked - assert tavernInstance.currentPrompt.text == "You have a headache." - tavernInstance.timeService.increaseDay.assert_called_once() + assert tavernInstance.player.money == 150 # Won 50 + assert tavernInstance.stats.totalMoneyMade == 50 + assert tavernInstance.currentBet == 0 + # Verify the message shows the actual bet amount won, not $0 + assert "You won $50!" in tavernInstance.currentPrompt.text + assert "You won $0!" not in tavernInstance.currentPrompt.text -def test_getDrunk_with_money_loss(): +def test_gamble_loss(): + # prepare + tavernInstance = createTavern() + tavernInstance.player.money = 100 + tavernInstance.currentBet = 50 + + # Test the loss logic directly + input_value = 1 + tavernInstance.diceThrow = 2 # Different from player choice + + # Execute the loss condition logic + tavernInstance.player.money -= tavernInstance.currentBet + tavernInstance.stats.moneyLostFromGambling += tavernInstance.currentBet + tavernInstance.currentBet = 0 + tavernInstance.currentPrompt.text = ( + "The dice rolled a %d! You lost your money! Care to try again? Current Bet: $%d" + % (tavernInstance.diceThrow, tavernInstance.currentBet) + ) + + # check + assert tavernInstance.player.money == 50 # Lost 50 + assert tavernInstance.stats.moneyLostFromGambling == 50 + assert tavernInstance.currentBet == 0 + assert "You lost your money!" in tavernInstance.currentPrompt.text + + +def test_changeBet_insufficient_money(): + # prepare + tavernInstance = createTavern() + tavernInstance.userInterface.lotsOfSpace = MagicMock() + tavernInstance.userInterface.divider = MagicMock() + tavernInstance.player.money = 50 + + # Mock input to simulate user entering more than they have + import builtins + + original_input = builtins.input + builtins.input = MagicMock(return_value="100") + + try: + # call + tavernInstance.changeBet("How much money would you like to bet? Money: $50") + + # check + # Bet should not be set since player doesn't have enough money + assert tavernInstance.currentBet == 0 + # Verify error message + assert "You don't have that much money" in tavernInstance.currentPrompt.text + finally: + # Restore original input function + builtins.input = original_input + + +def test_changeBet_invalid_input(): # prepare tavernInstance = createTavern() tavernInstance.userInterface.lotsOfSpace = MagicMock() tavernInstance.userInterface.divider = MagicMock() tavernInstance.player.money = 100 - initial_money = tavernInstance.player.money - tavern.print = MagicMock() - tavern.sys.stdout.flush = MagicMock() - tavern.time.sleep = MagicMock() - tavern.random.random = MagicMock(return_value=0.2) # Money loss (< 0.3) - tavern.random.uniform = MagicMock(return_value=0.3) # 30% loss - tavernInstance.timeService.increaseDay = MagicMock() - # call - tavernInstance.getDrunk() + # Mock input to simulate user entering invalid input + import builtins - # check - expected_loss = int((initial_money - 10) * 0.3) # 30% of remaining money after $10 cost - expected_money = initial_money - 10 - expected_loss - assert tavernInstance.player.money == expected_money - assert tavernInstance.stats.moneyLostWhileDrunk == expected_loss - assert f"You have a headache. In your drunken stupor, you lost ${expected_loss}!" in tavernInstance.currentPrompt.text - tavernInstance.timeService.increaseDay.assert_called_once() + original_input = builtins.input + builtins.input = MagicMock(return_value="not a number") + try: + # call + tavernInstance.changeBet("How much money would you like to bet? Money: $100") -def test_getDrunk_with_money_loss_no_money_remaining(): + # check + # Bet should remain 0 + assert tavernInstance.currentBet == 0 + # Verify error message + assert "Try again" in tavernInstance.currentPrompt.text + finally: + # Restore original input function + builtins.input = original_input + + +def test_getDrunk_updates_stats(): # prepare tavernInstance = createTavern() tavernInstance.userInterface.lotsOfSpace = MagicMock() tavernInstance.userInterface.divider = MagicMock() - tavernInstance.player.money = 10 # Only enough for the drink cost + tavernInstance.player.money = 20 + tavernInstance.stats.timesGottenDrunk = 0 tavern.print = MagicMock() tavern.sys.stdout.flush = MagicMock() tavern.time.sleep = MagicMock() - tavern.random.random = MagicMock(return_value=0.2) # Money loss scenario tavernInstance.timeService.increaseDay = MagicMock() # call tavernInstance.getDrunk() # check - assert tavernInstance.player.money == 0 # Only lost the $10 cost, no additional money to lose + assert tavernInstance.player.money == 10 # Lost $10 + assert tavernInstance.stats.timesGottenDrunk == 1 assert tavernInstance.currentPrompt.text == "You have a headache." - tavernInstance.timeService.increaseDay.assert_called_once() diff --git a/tests/npc/test_npc.py b/tests/npc/test_npc.py new file mode 100644 index 0000000..1075e1e --- /dev/null +++ b/tests/npc/test_npc.py @@ -0,0 +1,121 @@ +from src.npc.npc import NPC + + +def test_initialization(): + # call + npc = NPC("Shopkeeper", "A friendly merchant who loves fishing gear.") + + # check + assert npc.name == "Shopkeeper" + assert npc.backstory == "A friendly merchant who loves fishing gear." + + +def test_introduce(): + # prepare + npc = NPC("Barkeep", "An old sailor with many tales to tell.") + + # call + introduction = npc.introduce() + + # check + assert introduction == "Barkeep: An old sailor with many tales to tell." + + +def test_initialization_with_dialogue_options(): + # prepare + dialogue_options = [ + {"question": "How are you?", "response": "I'm doing well!"}, + {"question": "What do you sell?", "response": "I sell fishing gear."} + ] + + # call + npc = NPC("Merchant", "A trader of goods.", dialogue_options) + + # check + assert npc.name == "Merchant" + assert npc.backstory == "A trader of goods." + assert len(npc.dialogue_options) == 2 + assert npc.dialogue_options[0]["question"] == "How are you?" + + +def test_get_dialogue_options(): + # prepare + dialogue_options = [ + {"question": "Question 1", "response": "Answer 1"}, + {"question": "Question 2", "response": "Answer 2"} + ] + npc = NPC("Guide", "A helpful guide.", dialogue_options) + + # call + options = npc.get_dialogue_options() + + # check + assert len(options) == 2 + assert options[0]["question"] == "Question 1" + assert options[1]["response"] == "Answer 2" + + +def test_get_dialogue_response(): + # prepare + dialogue_options = [ + {"question": "Question 1", "response": "Answer 1"}, + {"question": "Question 2", "response": "Answer 2"} + ] + npc = NPC("Guide", "A helpful guide.", dialogue_options) + + # call + response1 = npc.get_dialogue_response(0) + response2 = npc.get_dialogue_response(1) + + # check + assert response1 == "Answer 1" + assert response2 == "Answer 2" + + +def test_get_dialogue_response_invalid_index(): + # prepare + dialogue_options = [ + {"question": "Question 1", "response": "Answer 1"} + ] + npc = NPC("Guide", "A helpful guide.", dialogue_options) + + # call + response_negative = npc.get_dialogue_response(-1) + response_too_large = npc.get_dialogue_response(10) + + # check + assert response_negative == "" + assert response_too_large == "" + + +def test_npc_without_dialogue_options(): + # call + npc = NPC("Simple NPC", "Just a simple character.") + + # check + assert npc.dialogue_options == [] + assert npc.get_dialogue_options() == [] + assert npc.get_dialogue_response(0) == "" + + +def test_npc_with_empty_list_preserves_identity(): + # prepare - create an empty list that we'll pass + empty_list = [] + original_id = id(empty_list) + + # call + npc = NPC("NPC", "A character.", empty_list) + + # check - the NPC should use the same list object, not create a new one + assert npc.dialogue_options is empty_list + assert id(npc.dialogue_options) == original_id + + # Verify behavior: if caller modifies the list, NPC sees the changes + # (This demonstrates why preserving identity matters) + test_option = {"question": "Test?", "response": "Test response"} + empty_list.append(test_option) + assert len(npc.get_dialogue_options()) == 1 + assert npc.get_dialogue_response(0) == "Test response" + + # Clean up the list to avoid side effects + empty_list.clear() diff --git a/tests/player/test_player.py b/tests/player/test_player.py index 8fe8efc..e140a8d 100644 --- a/tests/player/test_player.py +++ b/tests/player/test_player.py @@ -14,3 +14,4 @@ def test_initialization(): assert player.money == 20 assert player.moneyInBank == 0.01 assert player.fishMultiplier == 1 + assert player.energy == 100 diff --git a/tests/player/test_playerJsonReaderWriter.py b/tests/player/test_playerJsonReaderWriter.py index 75ccb7f..f83edb3 100644 --- a/tests/player/test_playerJsonReaderWriter.py +++ b/tests/player/test_playerJsonReaderWriter.py @@ -40,6 +40,7 @@ def test_createPlayerFromJson(): "money": 0, "moneyInBank": 0, "priceForBait": 50, + "energy": 100, } playerJsonReaderWriter = createPlayerJsonReaderWriter() @@ -49,3 +50,91 @@ def test_createPlayerFromJson(): assert player.fishMultiplier == playerJson["fishMultiplier"] assert player.money == playerJson["money"] assert player.moneyInBank == playerJson["moneyInBank"] + assert player.energy == playerJson["energy"] + + +def test_createPlayerFromJson_backwards_compatibility(): + # Test that old save files without energy still work + playerJson = { + "fishCount": 5, + "fishMultiplier": 2, + "money": 100, + "moneyInBank": 50, + "priceForBait": 75, + # Note: no energy field + } + + playerJsonReaderWriter = createPlayerJsonReaderWriter() + player = playerJsonReaderWriter.createPlayerFromJson(playerJson) + + assert player.fishCount == playerJson["fishCount"] + assert player.fishMultiplier == playerJson["fishMultiplier"] + assert player.money == playerJson["money"] + assert player.moneyInBank == playerJson["moneyInBank"] + assert player.energy == 100 # Should default to 100 + + +def test_writePlayerToFile(): + # prepare + import tempfile + + playerJsonReaderWriter = createPlayerJsonReaderWriter() + player = createPlayer() + player.fishCount = 10 + player.money = 500 + + # call + with tempfile.NamedTemporaryFile(mode="w+", delete=False, suffix=".json") as f: + playerJsonReaderWriter.writePlayerToFile(player, f) + temp_file_path = f.name + + # check - read back the file and verify + with open(temp_file_path, "r") as f: + playerJson = json.load(f) + + assert playerJson["fishCount"] == 10 + assert playerJson["money"] == 500 + + # cleanup + import os + + os.remove(temp_file_path) + + +def test_readPlayerFromFile(): + # prepare + import tempfile + + playerJson = { + "fishCount": 15, + "fishMultiplier": 3, + "money": 250, + "moneyInBank": 100, + "priceForBait": 60, + "energy": 80, + } + + # Write test data to temp file + with tempfile.NamedTemporaryFile( + mode="w", delete=False, suffix=".json" + ) as f: + json.dump(playerJson, f) + temp_file_path = f.name + + # call + playerJsonReaderWriter = createPlayerJsonReaderWriter() + with open(temp_file_path, "r") as f: + player = playerJsonReaderWriter.readPlayerFromFile(f) + + # check + assert player.fishCount == 15 + assert player.fishMultiplier == 3 + assert player.money == 250 + assert player.moneyInBank == 100 + assert player.priceForBait == 60 + assert player.energy == 80 + + # cleanup + import os + + os.remove(temp_file_path) diff --git a/tests/prompt/test_prompt.py b/tests/prompt/test_prompt.py new file mode 100644 index 0000000..26868bb --- /dev/null +++ b/tests/prompt/test_prompt.py @@ -0,0 +1,20 @@ +from src.prompt.prompt import Prompt + + +def test_initialization(): + # call + prompt = Prompt("Test prompt") + + # check + assert prompt.text == "Test prompt" + + +def test_text_can_be_modified(): + # prepare + prompt = Prompt("Initial text") + + # call + prompt.text = "Modified text" + + # check + assert prompt.text == "Modified text" diff --git a/tests/stats/test_statsJsonReaderWriter.py b/tests/stats/test_statsJsonReaderWriter.py index 96340df..075ab4c 100644 --- a/tests/stats/test_statsJsonReaderWriter.py +++ b/tests/stats/test_statsJsonReaderWriter.py @@ -43,7 +43,6 @@ def test_createStatsFromJson(): "moneyMadeFromInterest": 2, "timesGottenDrunk": 2, "moneyLostFromGambling": 2, - "moneyLostWhileDrunk": 5, } # validate @@ -58,27 +57,69 @@ def test_createStatsFromJson(): assert statsFromJson.moneyMadeFromInterest == 2 assert statsFromJson.timesGottenDrunk == 2 assert statsFromJson.moneyLostFromGambling == 2 - assert statsFromJson.moneyLostWhileDrunk == 5 -def test_createStatsFromJson_backward_compatibility(): - # Test that old save files without moneyLostWhileDrunk still work +def test_writeStatsToFile(): + # prepare + import tempfile + statsJsonReaderWriter = createStatsJsonReaderWriter() + stats = createStats() + stats.totalFishCaught = 50 + stats.totalMoneyMade = 1000 + + # call + with tempfile.NamedTemporaryFile(mode="w+", delete=False, suffix=".json") as f: + statsJsonReaderWriter.writeStatsToFile(stats, f) + temp_file_path = f.name + + # check - read back the file and verify + with open(temp_file_path, "r") as f: + statsJson = json.load(f) + + assert statsJson["totalFishCaught"] == 50 + assert statsJson["totalMoneyMade"] == 1000 + + # cleanup + import os + + os.remove(temp_file_path) + + +def test_readStatsFromFile(): + # prepare + import tempfile + statsJson = { - "totalFishCaught": 2, - "totalMoneyMade": 2, - "hoursSpentFishing": 2, - "moneyMadeFromInterest": 2, - "timesGottenDrunk": 2, - "moneyLostFromGambling": 2, + "totalFishCaught": 75, + "totalMoneyMade": 1500, + "hoursSpentFishing": 20, + "moneyMadeFromInterest": 100, + "timesGottenDrunk": 5, + "moneyLostFromGambling": 200, } - statsFromJson = statsJsonReaderWriter.createStatsFromJson(statsJson) - assert statsFromJson != None - assert statsFromJson.totalFishCaught == 2 - assert statsFromJson.totalMoneyMade == 2 - assert statsFromJson.hoursSpentFishing == 2 - assert statsFromJson.moneyMadeFromInterest == 2 - assert statsFromJson.timesGottenDrunk == 2 - assert statsFromJson.moneyLostFromGambling == 2 - assert statsFromJson.moneyLostWhileDrunk == 0 # Should default to 0 + # Write test data to temp file + with tempfile.NamedTemporaryFile( + mode="w", delete=False, suffix=".json" + ) as f: + json.dump(statsJson, f) + temp_file_path = f.name + + # call + statsJsonReaderWriter = createStatsJsonReaderWriter() + with open(temp_file_path, "r") as f: + stats = statsJsonReaderWriter.readStatsFromFile(f) + + # check + assert stats.totalFishCaught == 75 + assert stats.totalMoneyMade == 1500 + assert stats.hoursSpentFishing == 20 + assert stats.moneyMadeFromInterest == 100 + assert stats.timesGottenDrunk == 5 + assert stats.moneyLostFromGambling == 200 + + # cleanup + import os + + os.remove(temp_file_path) diff --git a/tests/test_fishE.py b/tests/test_fishE.py index 3473762..b83a542 100644 --- a/tests/test_fishE.py +++ b/tests/test_fishE.py @@ -1,10 +1,4 @@ -from unittest.mock import MagicMock -import sys -import os - -# Add src to the path so imports work correctly -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) - +from unittest.mock import MagicMock, patch from src import fishE @@ -13,7 +7,7 @@ def createFishE(): fishE.Stats = MagicMock() fishE.TimeService = MagicMock() fishE.Prompt = MagicMock() - fishE.UserInterfaceFactory = MagicMock() + fishE.UserInterface = MagicMock() fishE.bank.Bank = MagicMock() fishE.shop.Shop = MagicMock() fishE.home.Home = MagicMock() @@ -22,12 +16,21 @@ def createFishE(): fishE.PlayerJsonReaderWriter = MagicMock() fishE.TimeServiceJsonReaderWriter = MagicMock() fishE.StatsJsonReaderWriter = MagicMock() - fishE.ShopJsonReaderWriter = MagicMock() + fishE.SaveFileManager = MagicMock() fishE.loadPlayer = MagicMock() fishE.loadStats = MagicMock() fishE.loadTimeService = MagicMock() - fishE.loadShop = MagicMock() - return fishE.FishE() + + # 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): + return fishE.FishE() def test_initialization(): @@ -50,7 +53,7 @@ def test_initialization(): or fishEInstance.statsJsonReaderWriter.readStatsFromFile.call_count == 1 ) fishE.Prompt.assert_called_once() - fishE.UserInterfaceFactory.create_user_interface.assert_called_once() + fishE.UserInterface.assert_called_once() fishE.bank.Bank.assert_called_once() fishE.shop.Shop.assert_called_once() fishE.home.Home.assert_called_once() @@ -59,4 +62,4 @@ def test_initialization(): fishE.PlayerJsonReaderWriter.assert_called_once() fishE.TimeServiceJsonReaderWriter.assert_called_once() fishE.StatsJsonReaderWriter.assert_called_once() - fishE.ShopJsonReaderWriter.assert_called_once() + fishE.SaveFileManager.assert_called_once() diff --git a/tests/test_saveFileManager.py b/tests/test_saveFileManager.py new file mode 100644 index 0000000..b994f58 --- /dev/null +++ b/tests/test_saveFileManager.py @@ -0,0 +1,630 @@ +import os +import json +import tempfile +import shutil +import pytest +from unittest.mock import patch, MagicMock +from src.saveFileManager import SaveFileManager + + +def test_initialization(): + manager = SaveFileManager() + assert manager.data_directory == "data" + assert manager.selected_save_slot is None + + +def test_initialization_custom_directory(): + manager = SaveFileManager("custom_data") + assert manager.data_directory == "custom_data" + + +def test_list_save_files_empty(): + # Create temp directory + temp_dir = tempfile.mkdtemp() + try: + manager = SaveFileManager(temp_dir) + save_files = manager.list_save_files() + assert save_files == [] + finally: + shutil.rmtree(temp_dir) + + +def test_list_save_files_with_saves(): + # Create temp directory + temp_dir = tempfile.mkdtemp() + try: + manager = SaveFileManager(temp_dir) + + # Create a save slot + slot_path = os.path.join(temp_dir, "slot_1") + os.makedirs(slot_path) + + # Create player.json + player_data = {"money": 100, "fishCount": 5, "energy": 80} + with open(os.path.join(slot_path, "player.json"), "w") as f: + json.dump(player_data, f) + + # Create timeService.json + time_data = {"day": 3, "time": 10} + with open(os.path.join(slot_path, "timeService.json"), "w") as f: + json.dump(time_data, f) + + save_files = manager.list_save_files() + assert len(save_files) == 1 + assert save_files[0]["slot"] == 1 + assert save_files[0]["metadata"]["money"] == 100 + assert save_files[0]["metadata"]["fishCount"] == 5 + assert save_files[0]["metadata"]["day"] == 3 + finally: + shutil.rmtree(temp_dir) + + +def test_list_save_files_ignores_invalid_names(): + """Test that list_save_files ignores directories with invalid names""" + temp_dir = tempfile.mkdtemp() + try: + manager = SaveFileManager(temp_dir) + + # Create valid slot + slot_path = os.path.join(temp_dir, "slot_1") + os.makedirs(slot_path) + with open(os.path.join(slot_path, "player.json"), "w") as f: + json.dump({"money": 100}, f) + + # Create invalid directories that should be ignored + os.makedirs(os.path.join(temp_dir, "slot_abc")) # Non-numeric suffix + os.makedirs(os.path.join(temp_dir, "invalid_1")) # Wrong prefix + os.makedirs(os.path.join(temp_dir, "slot_")) # Missing number + os.makedirs(os.path.join(temp_dir, "slot_0")) # Slot 0 (invalid) + os.makedirs(os.path.join(temp_dir, "slot_100")) # Slot 100 (out of range) + + # Create a regular file (not a directory) + with open(os.path.join(temp_dir, "slot_2"), "w") as f: + f.write("not a directory") + + save_files = manager.list_save_files() + assert len(save_files) == 1 + assert save_files[0]["slot"] == 1 + finally: + shutil.rmtree(temp_dir) + + +def test_list_save_files_multiple_slots_sorted(): + """Test that multiple save slots are returned in sorted order""" + temp_dir = tempfile.mkdtemp() + try: + manager = SaveFileManager(temp_dir) + + # Create multiple slots in non-sequential order + for slot_num in [5, 1, 3]: + slot_path = os.path.join(temp_dir, f"slot_{slot_num}") + os.makedirs(slot_path) + with open(os.path.join(slot_path, "player.json"), "w") as f: + json.dump({"money": slot_num * 100}, f) + + save_files = manager.list_save_files() + assert len(save_files) == 3 + # Results should be in the order they were found (not necessarily sorted) + slot_numbers = [save["slot"] for save in save_files] + assert set(slot_numbers) == {1, 3, 5} + finally: + shutil.rmtree(temp_dir) + + +def test_list_save_files_oserror_handling(): + """Test that list_save_files returns empty list on OSError""" + manager = SaveFileManager("/non/existent/path") + + # This should not raise an exception + save_files = manager.list_save_files() + assert save_files == [] + + +def test_get_next_available_slot_empty(): + temp_dir = tempfile.mkdtemp() + try: + manager = SaveFileManager(temp_dir) + next_slot = manager.get_next_available_slot() + assert next_slot == 1 + finally: + shutil.rmtree(temp_dir) + + +def test_get_next_available_slot_with_existing(): + temp_dir = tempfile.mkdtemp() + try: + manager = SaveFileManager(temp_dir) + + # Create slot 1 + slot_path = os.path.join(temp_dir, "slot_1") + os.makedirs(slot_path) + with open(os.path.join(slot_path, "player.json"), "w") as f: + json.dump({"money": 0}, f) + + next_slot = manager.get_next_available_slot() + assert next_slot == 2 + finally: + shutil.rmtree(temp_dir) + + +def test_get_next_available_slot_with_gap(): + temp_dir = tempfile.mkdtemp() + try: + manager = SaveFileManager(temp_dir) + + # Create slot 1 and 3 (gap at 2) + for slot_num in [1, 3]: + slot_path = os.path.join(temp_dir, f"slot_{slot_num}") + os.makedirs(slot_path) + with open(os.path.join(slot_path, "player.json"), "w") as f: + json.dump({"money": 0}, f) + + next_slot = manager.get_next_available_slot() + assert next_slot == 2 + finally: + shutil.rmtree(temp_dir) + + +def test_get_next_available_slot_all_full(): + temp_dir = tempfile.mkdtemp() + try: + manager = SaveFileManager(temp_dir) + + # Create 99 save slots + for i in range(1, 100): + slot_path = os.path.join(temp_dir, f"slot_{i}") + os.makedirs(slot_path) + with open(os.path.join(slot_path, "player.json"), "w") as f: + json.dump({"money": 0}, f) + + next_slot = manager.get_next_available_slot() + assert next_slot is None + finally: + shutil.rmtree(temp_dir) + + +def test_select_save_slot(): + manager = SaveFileManager() + manager.select_save_slot(5) + assert manager.selected_save_slot == 5 + + +def test_select_save_slot_boundary_values(): + """Test selecting boundary slot values""" + manager = SaveFileManager() + + # Test slot 1 (minimum valid) + manager.select_save_slot(1) + assert manager.selected_save_slot == 1 + + # Test slot 99 (maximum valid) + manager.select_save_slot(99) + assert manager.selected_save_slot == 99 + + # Test slot 0 (edge case - technically allowed by select but not recommended) + manager.select_save_slot(0) + assert manager.selected_save_slot == 0 + + +def test_get_save_path(): + temp_dir = tempfile.mkdtemp() + try: + manager = SaveFileManager(temp_dir) + manager.select_save_slot(1) + + path = manager.get_save_path("player.json") + expected = os.path.join(temp_dir, "slot_1", "player.json") + assert path == expected + + # Check that directory was created + assert os.path.exists(os.path.join(temp_dir, "slot_1")) + finally: + shutil.rmtree(temp_dir) + + +def test_get_save_path_creates_directory(): + """Test that get_save_path creates the slot directory if it doesn't exist""" + temp_dir = tempfile.mkdtemp() + try: + manager = SaveFileManager(temp_dir) + manager.select_save_slot(5) + + slot_path = os.path.join(temp_dir, "slot_5") + assert not os.path.exists(slot_path) + + # Calling get_save_path should create the directory + path = manager.get_save_path("player.json") + assert os.path.exists(slot_path) + assert os.path.isdir(slot_path) + finally: + shutil.rmtree(temp_dir) + + +def test_get_save_path_multiple_files(): + """Test getting paths for multiple files in the same slot""" + temp_dir = tempfile.mkdtemp() + try: + manager = SaveFileManager(temp_dir) + manager.select_save_slot(1) + + player_path = manager.get_save_path("player.json") + stats_path = manager.get_save_path("stats.json") + time_path = manager.get_save_path("timeService.json") + + # All should be in the same slot directory + assert os.path.dirname(player_path) == os.path.dirname(stats_path) + assert os.path.dirname(stats_path) == os.path.dirname(time_path) + + # But different files + assert player_path != stats_path + assert stats_path != time_path + finally: + shutil.rmtree(temp_dir) + + +def test_get_save_path_no_slot_selected(): + manager = SaveFileManager() + with pytest.raises(ValueError, match="No save slot selected"): + manager.get_save_path("player.json") + + +def test_delete_save_slot(): + temp_dir = tempfile.mkdtemp() + try: + manager = SaveFileManager(temp_dir) + + # Create a save slot + slot_path = os.path.join(temp_dir, "slot_1") + os.makedirs(slot_path) + with open(os.path.join(slot_path, "player.json"), "w") as f: + json.dump({"money": 100}, f) + + assert os.path.exists(slot_path) + + # Delete it + result = manager.delete_save_slot(1) + assert result is True + assert not os.path.exists(slot_path) + finally: + shutil.rmtree(temp_dir) + + +def test_delete_nonexistent_save_slot(): + temp_dir = tempfile.mkdtemp() + try: + manager = SaveFileManager(temp_dir) + result = manager.delete_save_slot(99) + assert result is False + finally: + shutil.rmtree(temp_dir) + + +def test_delete_save_slot_with_multiple_files(): + """Test that deleting a slot removes all files in it""" + temp_dir = tempfile.mkdtemp() + try: + manager = SaveFileManager(temp_dir) + + # Create a save slot with multiple files + slot_path = os.path.join(temp_dir, "slot_1") + os.makedirs(slot_path) + with open(os.path.join(slot_path, "player.json"), "w") as f: + json.dump({"money": 100}, f) + with open(os.path.join(slot_path, "stats.json"), "w") as f: + json.dump({"totalFishCaught": 50}, f) + with open(os.path.join(slot_path, "timeService.json"), "w") as f: + json.dump({"day": 5}, f) + + # Delete the slot + result = manager.delete_save_slot(1) + assert result is True + assert not os.path.exists(slot_path) + finally: + shutil.rmtree(temp_dir) + + +def test_multiple_save_files_dont_conflict(): + temp_dir = tempfile.mkdtemp() + try: + manager = SaveFileManager(temp_dir) + + # Create slot 1 + manager.select_save_slot(1) + path1 = manager.get_save_path("player.json") + with open(path1, "w") as f: + json.dump({"money": 100}, f) + + # Create slot 2 + manager.select_save_slot(2) + path2 = manager.get_save_path("player.json") + with open(path2, "w") as f: + json.dump({"money": 200}, f) + + # Verify both exist and are different + assert os.path.exists(path1) + assert os.path.exists(path2) + assert path1 != path2 + + with open(path1, "r") as f: + data1 = json.load(f) + with open(path2, "r") as f: + data2 = json.load(f) + + assert data1["money"] == 100 + assert data2["money"] == 200 + finally: + shutil.rmtree(temp_dir) + + +def test_read_save_metadata_missing_files(): + temp_dir = tempfile.mkdtemp() + try: + manager = SaveFileManager(temp_dir) + + # Create empty slot directory + slot_path = os.path.join(temp_dir, "slot_1") + os.makedirs(slot_path) + + metadata = manager._read_save_metadata(slot_path) + assert metadata is None + finally: + shutil.rmtree(temp_dir) + + +def test_read_save_metadata_corrupted_json(): + temp_dir = tempfile.mkdtemp() + try: + manager = SaveFileManager(temp_dir) + + # Create slot with corrupted json + slot_path = os.path.join(temp_dir, "slot_1") + os.makedirs(slot_path) + with open(os.path.join(slot_path, "player.json"), "w") as f: + f.write("invalid json{") + + metadata = manager._read_save_metadata(slot_path) + assert metadata is None + finally: + shutil.rmtree(temp_dir) + + +def test_read_save_metadata_empty_player_file(): + """Test reading metadata from an empty player.json file""" + temp_dir = tempfile.mkdtemp() + try: + manager = SaveFileManager(temp_dir) + + slot_path = os.path.join(temp_dir, "slot_1") + os.makedirs(slot_path) + + # Create empty player.json + with open(os.path.join(slot_path, "player.json"), "w") as f: + f.write("") + + metadata = manager._read_save_metadata(slot_path) + # Empty file still returns metadata with last_modified but no game data + assert metadata is not None + assert "last_modified" in metadata + # Game data fields should not be present since file is empty + assert "money" not in metadata + assert "fishCount" not in metadata + finally: + shutil.rmtree(temp_dir) + + +def test_read_save_metadata_partial_data(): + """Test reading metadata when only some fields are present""" + temp_dir = tempfile.mkdtemp() + try: + manager = SaveFileManager(temp_dir) + + slot_path = os.path.join(temp_dir, "slot_1") + os.makedirs(slot_path) + + # Create player.json with minimal data + with open(os.path.join(slot_path, "player.json"), "w") as f: + json.dump({"money": 50}, f) # Missing fishCount and energy + + metadata = manager._read_save_metadata(slot_path) + assert metadata is not None + assert metadata["money"] == 50 + assert metadata["fishCount"] == 0 # Default value + assert metadata["energy"] == 100 # Default value + assert "last_modified" in metadata + finally: + shutil.rmtree(temp_dir) + + +def test_read_save_metadata_missing_timeservice(): + """Test reading metadata when timeService.json is missing""" + temp_dir = tempfile.mkdtemp() + try: + manager = SaveFileManager(temp_dir) + + slot_path = os.path.join(temp_dir, "slot_1") + os.makedirs(slot_path) + + # Create only player.json + with open(os.path.join(slot_path, "player.json"), "w") as f: + json.dump({"money": 100, "fishCount": 10}, f) + + metadata = manager._read_save_metadata(slot_path) + assert metadata is not None + assert metadata["money"] == 100 + assert metadata["fishCount"] == 10 + # Time data should not be present or have defaults + assert "day" not in metadata or metadata["day"] == 1 + assert "time" not in metadata or metadata["time"] == 0 + finally: + shutil.rmtree(temp_dir) + + +def test_migrate_old_save_files(): + temp_dir = tempfile.mkdtemp() + try: + manager = SaveFileManager(temp_dir) + + # Create old format save files + os.makedirs(temp_dir, exist_ok=True) + with open(os.path.join(temp_dir, "player.json"), "w") as f: + json.dump({"money": 100, "fishCount": 5}, f) + with open(os.path.join(temp_dir, "stats.json"), "w") as f: + json.dump({"totalFishCaught": 10}, f) + with open(os.path.join(temp_dir, "timeService.json"), "w") as f: + json.dump({"day": 2}, f) + + # Migrate + result = manager.migrate_old_save_files() + assert result is True + + # Check that files were moved to slot_1 + assert os.path.exists(os.path.join(temp_dir, "slot_1", "player.json")) + assert os.path.exists(os.path.join(temp_dir, "slot_1", "stats.json")) + assert os.path.exists(os.path.join(temp_dir, "slot_1", "timeService.json")) + + # Check that old files are gone + assert not os.path.exists(os.path.join(temp_dir, "player.json")) + assert not os.path.exists(os.path.join(temp_dir, "stats.json")) + assert not os.path.exists(os.path.join(temp_dir, "timeService.json")) + finally: + shutil.rmtree(temp_dir) + + +def test_migrate_old_save_files_no_old_saves(): + temp_dir = tempfile.mkdtemp() + try: + manager = SaveFileManager(temp_dir) + result = manager.migrate_old_save_files() + assert result is False + finally: + shutil.rmtree(temp_dir) + + +def test_migrate_old_save_files_partial(): + """Test migration when only some old files exist""" + temp_dir = tempfile.mkdtemp() + try: + manager = SaveFileManager(temp_dir) + + # Create only player.json (missing stats and timeService) + with open(os.path.join(temp_dir, "player.json"), "w") as f: + json.dump({"money": 100}, f) + + result = manager.migrate_old_save_files() + assert result is True + + # Check that player.json was moved + assert os.path.exists(os.path.join(temp_dir, "slot_1", "player.json")) + assert not os.path.exists(os.path.join(temp_dir, "player.json")) + + # Other files shouldn't exist in either location + assert not os.path.exists(os.path.join(temp_dir, "stats.json")) + assert not os.path.exists(os.path.join(temp_dir, "slot_1", "stats.json")) + finally: + shutil.rmtree(temp_dir) + + +def test_migrate_old_save_files_slot1_exists(): + """Test migration when slot_1 already exists""" + temp_dir = tempfile.mkdtemp() + try: + manager = SaveFileManager(temp_dir) + + # Create existing slot_1 with data + slot_1_path = os.path.join(temp_dir, "slot_1") + os.makedirs(slot_1_path) + with open(os.path.join(slot_1_path, "player.json"), "w") as f: + json.dump({"money": 999}, f) # Existing data + + # Create old format files + with open(os.path.join(temp_dir, "player.json"), "w") as f: + json.dump({"money": 100}, f) + + # Migration should still succeed (will overwrite) + result = manager.migrate_old_save_files() + assert result is True + + # Check that old file was moved and overwrote existing + with open(os.path.join(slot_1_path, "player.json"), "r") as f: + data = json.load(f) + assert data["money"] == 100 # Should be the migrated value + finally: + shutil.rmtree(temp_dir) + + +def test_integration_create_save_and_delete(): + """Integration test: create multiple saves and delete one""" + temp_dir = tempfile.mkdtemp() + try: + manager = SaveFileManager(temp_dir) + + # Create slot 1 + manager.select_save_slot(1) + path1 = manager.get_save_path("player.json") + with open(path1, "w") as f: + json.dump({"money": 100}, f) + + # Create slot 2 + manager.select_save_slot(2) + path2 = manager.get_save_path("player.json") + with open(path2, "w") as f: + json.dump({"money": 200}, f) + + # List should show both + saves = manager.list_save_files() + assert len(saves) == 2 + + # Delete slot 1 + manager.delete_save_slot(1) + + # List should show only slot 2 + saves = manager.list_save_files() + assert len(saves) == 1 + assert saves[0]["slot"] == 2 + + # Next available should be 1 (the gap) + next_slot = manager.get_next_available_slot() + assert next_slot == 1 + finally: + shutil.rmtree(temp_dir) + + +def test_integration_full_workflow(): + """Integration test: simulate a full user workflow""" + temp_dir = tempfile.mkdtemp() + try: + manager = SaveFileManager(temp_dir) + + # Start with no saves + assert manager.list_save_files() == [] + assert manager.get_next_available_slot() == 1 + + # Create first save + manager.select_save_slot(1) + player_path = manager.get_save_path("player.json") + with open(player_path, "w") as f: + json.dump({"money": 100, "fishCount": 10, "energy": 85}, f) + time_path = manager.get_save_path("timeService.json") + with open(time_path, "w") as f: + json.dump({"day": 5, "time": 12}, f) + + # List saves and verify metadata + saves = manager.list_save_files() + assert len(saves) == 1 + assert saves[0]["metadata"]["money"] == 100 + assert saves[0]["metadata"]["day"] == 5 + + # Create second save + manager.select_save_slot(2) + player_path2 = manager.get_save_path("player.json") + with open(player_path2, "w") as f: + json.dump({"money": 500, "fishCount": 50, "energy": 100}, f) + + # Verify two saves exist + saves = manager.list_save_files() + assert len(saves) == 2 + + # Next slot should be 3 + assert manager.get_next_available_slot() == 3 + finally: + shutil.rmtree(temp_dir) diff --git a/tests/ui/test_userInterface.py b/tests/ui/test_userInterface.py index 4405e07..cf023f2 100644 --- a/tests/ui/test_userInterface.py +++ b/tests/ui/test_userInterface.py @@ -1,15 +1,9 @@ -import sys -import os - -# Add src to the path so imports work correctly -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', 'src')) - -from player.player import Player -from prompt.prompt import Prompt -from stats.stats import Stats -from ui.consoleUserInterface import ConsoleUserInterface -from world.timeService import TimeService -from unittest.mock import patch +from src.player.player import Player +from src.prompt.prompt import Prompt +from src.stats.stats import Stats +from src.ui import userInterface +from src.world.timeService import TimeService +from unittest.mock import MagicMock def createUserInterface(): @@ -17,7 +11,7 @@ def createUserInterface(): player = Player() stats = Stats() timeService = TimeService(player, stats) - userInterfaceInstance = ConsoleUserInterface( + userInterfaceInstance = userInterface.UserInterface( currentPrompt, timeService, player ) return userInterfaceInstance @@ -36,41 +30,127 @@ def test_initialization(): def test_lotsOfSpace(): # setup userInterfaceInstance = createUserInterface() - - # call with patch - with patch('builtins.print') as mock_print: - userInterfaceInstance.lotsOfSpace() - - # check - mock_print.assert_called_with("\n" * 20) + userInterface.print = MagicMock() + + # call + userInterfaceInstance.lotsOfSpace() + + # check + userInterface.print.assert_called_with("\n" * 20) def test_divider(): # setup userInterfaceInstance = createUserInterface() - - # call with patch - with patch('builtins.print') as mock_print: - userInterfaceInstance.divider() - - # check - assert mock_print.call_count == 3 + userInterface.print = MagicMock() + + # call + userInterfaceInstance.divider() + + # check + assert userInterface.print.call_count == 3 def test_showOptions(): # setup userInterfaceInstance = createUserInterface() + userInterface.print = MagicMock() + userInterface.input = MagicMock(return_value="1") + userInterfaceInstance.lotsOfSpace = MagicMock() + userInterfaceInstance.divider = MagicMock() + + # call + userInterfaceInstance.showOptions("descriptor", ["option1", "option2"]) + + # check + assert userInterface.print.call_count == 9 + userInterfaceInstance.lotsOfSpace.assert_called() + assert userInterfaceInstance.divider.call_count == 3 + userInterface.input.assert_called_with("\n> ") + + +def test_showDialogue(): + # setup + userInterfaceInstance = createUserInterface() + userInterface.print = MagicMock() + userInterface.input = MagicMock(return_value="") + userInterfaceInstance.lotsOfSpace = MagicMock() + userInterfaceInstance.divider = MagicMock() + + # call + userInterfaceInstance.showDialogue("Test dialogue text") + + # check + userInterfaceInstance.lotsOfSpace.assert_called_once() + assert userInterfaceInstance.divider.call_count == 2 + userInterface.print.assert_called_with("Test dialogue text") + userInterface.input.assert_called_with(" [ CONTINUE ]") + assert userInterfaceInstance.currentPrompt.text == "What would you like to do?" + + +def test_showInteractiveDialogue_with_no_options(): + # setup + from src.npc.npc import NPC + userInterfaceInstance = createUserInterface() + userInterface.print = MagicMock() + userInterface.input = MagicMock(return_value="") + userInterfaceInstance.lotsOfSpace = MagicMock() + userInterfaceInstance.divider = MagicMock() + npc = NPC("Test NPC", "A test character") + + # call + userInterfaceInstance.showInteractiveDialogue(npc) + + # check - should fallback to simple introduction + userInterfaceInstance.lotsOfSpace.assert_called_once() + assert userInterfaceInstance.divider.call_count == 3 + userInterface.input.assert_called_with(" [ CONTINUE ]") + assert userInterfaceInstance.currentPrompt.text == "What would you like to do?" + + +def test_showInteractiveDialogue_select_option(): + # setup + from src.npc.npc import NPC + userInterfaceInstance = createUserInterface() + userInterface.print = MagicMock() + # First input selects option 1, second input continues, third input selects Back + userInterface.input = MagicMock(side_effect=["1", "", "2"]) + userInterfaceInstance.lotsOfSpace = MagicMock() + userInterfaceInstance.divider = MagicMock() - # call with patches - with patch('builtins.print') as mock_print, \ - patch('builtins.input', return_value="1") as mock_input, \ - patch.object(userInterfaceInstance, 'lotsOfSpace') as mock_lots_of_space, \ - patch.object(userInterfaceInstance, 'divider') as mock_divider: - - result = userInterfaceInstance.showOptions("descriptor", ["option1", "option2"]) - - # check - assert result == "1" - mock_lots_of_space.assert_called() - assert mock_divider.call_count == 3 - mock_input.assert_called_with("\n> ") + dialogue_options = [ + {"question": "Test question?", "response": "Test response"} + ] + npc = NPC("Test NPC", "A test character", dialogue_options) + + # call + userInterfaceInstance.showInteractiveDialogue(npc) + + # check - should have shown menu, response, and back option + assert userInterface.input.call_count == 3 + assert userInterfaceInstance.currentPrompt.text == "What would you like to do?" + + +def test_showInteractiveDialogue_invalid_choice(): + # setup + from src.npc.npc import NPC + userInterfaceInstance = createUserInterface() + userInterface.print = MagicMock() + # First input is invalid, second continues error message, third selects Back + userInterface.input = MagicMock(side_effect=["99", "", "2"]) + userInterfaceInstance.lotsOfSpace = MagicMock() + userInterfaceInstance.divider = MagicMock() + + dialogue_options = [ + {"question": "Test question?", "response": "Test response"} + ] + npc = NPC("Test NPC", "A test character", dialogue_options) + + # call + userInterfaceInstance.showInteractiveDialogue(npc) + + # check - should have handled invalid input + assert userInterface.input.call_count == 3 + # Should have printed "Invalid choice" message + print_calls = [str(call) for call in userInterface.print.call_args_list] + assert any("Invalid choice" in str(call) for call in print_calls) diff --git a/tests/world/test_timeServiceJsonReaderWriter.py b/tests/world/test_timeServiceJsonReaderWriter.py index 1829a8d..6d33a4c 100644 --- a/tests/world/test_timeServiceJsonReaderWriter.py +++ b/tests/world/test_timeServiceJsonReaderWriter.py @@ -52,3 +52,62 @@ def test_createTimeServiceFromJson(): timeServiceJson, player, stats ) assert timeServiceFromJson != None + + +def test_writeTimeServiceToFile(): + # prepare + import tempfile + + timeServiceJsonReaderWriter = createTimeServiceJsonReaderWriter() + timeService = createTimeService() + timeService.time = 15 + timeService.day = 10 + + # call + with tempfile.NamedTemporaryFile(mode="w+", delete=False, suffix=".json") as f: + timeServiceJsonReaderWriter.writeTimeServiceToFile(timeService, f) + temp_file_path = f.name + + # check - read back the file and verify + with open(temp_file_path, "r") as f: + timeServiceJson = json.load(f) + + assert timeServiceJson["time"] == 15 + assert timeServiceJson["day"] == 10 + + # cleanup + import os + + os.remove(temp_file_path) + + +def test_readTimeServiceFromFile(): + # prepare + import tempfile + + timeServiceJson = {"time": 12, "day": 5} + + # Write test data to temp file + with tempfile.NamedTemporaryFile( + mode="w", delete=False, suffix=".json" + ) as f: + json.dump(timeServiceJson, f) + temp_file_path = f.name + + # call + timeServiceJsonReaderWriter = createTimeServiceJsonReaderWriter() + player = Player() + stats = Stats() + with open(temp_file_path, "r") as f: + timeService = timeServiceJsonReaderWriter.readTimeServiceFromFile( + f, player, stats + ) + + # check + assert timeService.time == 12 + assert timeService.day == 5 + + # cleanup + import os + + os.remove(temp_file_path)