diff --git a/images/exploration-screen-01.png b/images/exploration-screen-01.png index d061dae..ee196b5 100644 Binary files a/images/exploration-screen-01.png and b/images/exploration-screen-01.png differ diff --git a/osrgame/osrgame/osrgame.py b/osrgame/osrgame/osrgame.py index f2bcafc..fac1983 100644 --- a/osrgame/osrgame/osrgame.py +++ b/osrgame/osrgame/osrgame.py @@ -1,3 +1,4 @@ +import random from textual.app import App, ComposeResult from screen_character import CharacterScreen from screen_welcome import WelcomeScreen @@ -5,6 +6,7 @@ from screen_adventure_browser import AdventureBrowserScreen from osrlib.adventure import Adventure +from osrlib.constants import ADVENTURE_NAMES, DUNGEON_NAMES from osrlib.dungeon import Dungeon from osrlib.dungeon_master import DungeonMaster from osrlib.game_manager import logger @@ -57,7 +59,7 @@ def set_active_adventure(self, adventure: Adventure = None) -> None: if adventure is not None: self.adventure = adventure else: - default_adventure = Adventure("Raving Mad Under the Mystic Forest") + default_adventure = Adventure(random.choice(ADVENTURE_NAMES)) default_adventure.description = "An adventure for 4-6 characters of levels 1-3." default_adventure.introduction = ( "In the heart of the cursed Mystic Forest, a tale as old as time stirs once again. Legends " @@ -72,7 +74,7 @@ def set_active_adventure(self, adventure: Adventure = None) -> None: "Mad Mage." ) - dungeon = Dungeon.get_random_dungeon("Dungeon of the Raving Mage", + dungeon = Dungeon.get_random_dungeon(random.choice(DUNGEON_NAMES), "The first level of the home of the ancient wizard lich Glofarnux, its " "entrance hidden in a forgotten glade deep in the cursed Mystic Forest.", num_locations=50, use_ai=True, openai_model=self.openai_model) diff --git a/osrgame/osrgame/screen_adventure_browser.py b/osrgame/osrgame/screen_adventure_browser.py index 93c2214..4459dc3 100644 --- a/osrgame/osrgame/screen_adventure_browser.py +++ b/osrgame/osrgame/screen_adventure_browser.py @@ -9,6 +9,7 @@ from textual.widgets import Footer, Header, Static from osrlib.adventure import Adventure +from osrlib.utils import get_data_dir_path from widgets import JsonFilteredDirectoryTree @@ -24,7 +25,7 @@ class AdventureBrowserScreen(Screen): def compose(self) -> ComposeResult: """Compose our UI.""" # Set the path to the current user's home directory. - path = os.path.expanduser("~") + "/" + path = get_data_dir_path("osrlib")# / "adventures" yield Header() with Container(): yield JsonFilteredDirectoryTree(path, id="tree-view") diff --git a/osrgame/osrgame/screen_character.py b/osrgame/osrgame/screen_character.py index b286daa..dd5c681 100644 --- a/osrgame/osrgame/screen_character.py +++ b/osrgame/osrgame/screen_character.py @@ -1,6 +1,9 @@ +from typing import Any, Coroutine +from textual import events from textual.app import ComposeResult from textual.screen import Screen from textual.widgets import Button, Header, Footer, Log +from textual.events import Event from widgets import CharacterStatsBox, AbilityTable, ItemTable, SavingThrowTable, CharacterScreenButtons @@ -58,6 +61,14 @@ def action_next_character(self) -> None: self.app.adventure.active_party.set_next_character_as_active() self.on_mount() + + def on_event(self, event: Event) -> Coroutine[Any, Any, None]: + """Handle events.""" + # HACK: This is a hack to get the screen to update when the user switches to it. + if isinstance(event, events.ScreenResume): + self.on_mount() + return super().on_event(event) + def reroll(self): """Rolls the ability scores of the active character.""" self.app.adventure.active_party.active_character.roll_abilities() diff --git a/osrgame/osrgame/screen_explore.py b/osrgame/osrgame/screen_explore.py index 449b7fe..237c9a5 100644 --- a/osrgame/osrgame/screen_explore.py +++ b/osrgame/osrgame/screen_explore.py @@ -45,7 +45,7 @@ def perform_move_action(self, direction: Direction, log_message: str) -> None: dm_response = self.dungeon_master.move_party(direction) self.query_one("#dm_log").write_line( - "> " + str(self.dungeon_master.adventure.active_dungeon.current_location) + ""#"> " + str(self.dungeon_master.adventure.active_dungeon.current_location) ) self.query_one("#dm_log").write_line(wrap_text(dm_response)) diff --git a/osrgame/osrgame/widgets.py b/osrgame/osrgame/widgets.py index d38dfb6..ef96d91 100644 --- a/osrgame/osrgame/widgets.py +++ b/osrgame/osrgame/widgets.py @@ -7,13 +7,13 @@ from textual.widgets import Button, DataTable, Log, Static, DirectoryTree -from osrlib import Item +from osrlib.item import Item from osrlib.utils import format_modifiers class WelcomeScreenButtons(Container): def compose(self) -> ComposeResult: - yield Button("Start default adventure", id="btn-adventure-default", classes="button") + yield Button("Random adventure", id="btn-adventure-default", classes="button") yield Button("Load adventure", id="btn-adventure-load", classes="button") yield Button("Create adventure", id="btn-adventure-create", classes="button") yield Button("Quit", id="btn-quit", classes="button") @@ -33,7 +33,7 @@ class CharacterStatsBox(Container): pc_hp = reactive(0) pc_ac = reactive(0) - BORDER_TITLE = "CHARACTER RECORD SHEET" + BORDER_TITLE = "Character Record Sheet" def compose(self) -> ComposeResult: yield Static(id="name") @@ -110,6 +110,12 @@ def update_table(self): ] table.add_row(*row_data, key=pc.name) + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + """Called when the user selects a row in the party roster table.""" + event.stop() + party = self.app.adventure.active_party + party.set_active_character(party.members[event.cursor_row]) + #self.update_table() class SavingThrowTable(Container): def compose(self) -> ComposeResult: diff --git a/osrgame/poetry.lock b/osrgame/poetry.lock index 0123bce..413ba69 100644 --- a/osrgame/poetry.lock +++ b/osrgame/poetry.lock @@ -1006,13 +1006,13 @@ files = [ [[package]] name = "openai" -version = "1.3.5" +version = "1.3.6" description = "The official Python library for the openai API" optional = false python-versions = ">=3.7.1" files = [ - {file = "openai-1.3.5-py3-none-any.whl", hash = "sha256:9437458978fb502e61336c3082e02b09c49feebe0e8516a2b8fb4563e6e4af4e"}, - {file = "openai-1.3.5.tar.gz", hash = "sha256:163e7ece4af76e961f58b75ea20a42b0d0c2a240c2f81b41a3d1c5962463cdf8"}, + {file = "openai-1.3.6-py3-none-any.whl", hash = "sha256:a88063de50706e7a25d9d3dcc0aa4b7e1f59ad40a3b8901af28fec097abeb230"}, + {file = "openai-1.3.6.tar.gz", hash = "sha256:5f1f00658474668d8ecb7e87ceed101632c66e1e57d29aa9e0b1cb21f65a72be"}, ] [package.dependencies] @@ -1020,6 +1020,7 @@ anyio = ">=3.5.0,<4" distro = ">=1.7.0,<2" httpx = ">=0.23.0,<1" pydantic = ">=1.9.0,<3" +sniffio = "*" tqdm = ">4" typing-extensions = ">=4.5,<5" @@ -1028,7 +1029,7 @@ datalib = ["numpy (>=1)", "pandas (>=1.2.3)", "pandas-stubs (>=1.1.0.11)"] [[package]] name = "osrlib" -version = "0.1.27" +version = "0.1.43" description = "Turn-based dungeon-crawler game engine for OSR-style RPGs." optional = false python-versions = "^3.11" diff --git a/osrlib/osrlib/__init__.py b/osrlib/osrlib/__init__.py index fd1cd1f..9ab4f6b 100644 --- a/osrlib/osrlib/__init__.py +++ b/osrlib/osrlib/__init__.py @@ -1,98 +1 @@ -from .ability import ( - AbilityType, - Ability, - Strength, - Dexterity, - Constitution, - Intelligence, - Wisdom, - Charisma, -) -from .adventure import ( - Adventure, -) -from .character_classes import ( - CharacterClass, - CharacterClassType, - ClassLevel, - all_character_classes, - class_levels, - cleric_levels, - commoner_levels, - dwarf_levels, - elf_levels, - fighter_levels, - halfling_levels, - magic_user_levels, - thief_levels, -) -from .combat import ( - AttackType, - ModifierType, -) -from .dice_roller import ( - DiceRoll, - roll_dice, -) -from .dungeon_master import ( - DungeonMaster, -) -from .dungeon import ( - Dungeon, - Encounter, - Exit, - Location, - LocationNotFoundError, - Direction, -) -from .enums import ( - CharacterClassType, - PartyReaction, - OpenAIModelVersion, -) -from .game_manager import ( - GameManager, - StorageType, -) -from .inventory import Inventory -from .item import ( - Item, - ItemType, - Armor, - Weapon, - Spell, - ItemAlreadyHasOwnerError, - ItemAlreadyInInventoryError, - ItemAlreadyInQuestError, - ItemEquippedError, - ItemNotEquippedError, - ItemNotInInventoryError, - ItemNotUsableError, -) -from .item_factories import ( - armor_data, - magic_armor_data, - ArmorFactory, - equipment_data, - EquipmentFactory, - weapon_data, - magic_weapon_data, - WeaponFactory, - ItemDataNotFoundError, - equip_party, -) -from .monster import ( - Monster, -) -from .party import ( - Party, - PartyAtCapacityError, - PartyInStartedAdventureError, - CharacterNotInPartyError, - CharacterAlreadyInPartyError, - get_default_party, -) -from .player_character import PlayerCharacter, Alignment -from .quest import Quest -from .saving_throws import saving_throws -from .utils import format_modifiers +"""OSR Library is a Python library for powering old-school turn-based role-playing games (OSRPGs).""" diff --git a/osrlib/osrlib/adventure.py b/osrlib/osrlib/adventure.py index 674d145..d214e03 100644 --- a/osrlib/osrlib/adventure.py +++ b/osrlib/osrlib/adventure.py @@ -3,6 +3,7 @@ from osrlib.dungeon import Dungeon from osrlib.party import Party from osrlib.quest import Quest +from osrlib.utils import get_data_dir_path, create_dir_tree_if_not_exist class DungeonNotFoundError(Exception): @@ -168,7 +169,7 @@ def save_adventure(self, file_path: str = None) -> str: Args: file_path (str, optional): The path where the file will be saved. - If None, saves in the user's home directory. + If None, saves in the default data directory. Returns: str: The path where the file was saved. @@ -183,8 +184,9 @@ def save_adventure(self, file_path: str = None) -> str: now = datetime.datetime.now() timestamp = now.strftime("%Y%m%d_%H%M%S") # YYYYMMDD_HHMMSS filename = f"{self.name}_{timestamp}.json".replace(" ", "_").lower() - home_dir = os.path.expanduser("~") - file_path = os.path.join(home_dir, filename) + save_dir = get_data_dir_path("osrlib")# / "adventures" + create_dir_tree_if_not_exist(save_dir) + file_path = save_dir / filename try: with open(file_path, "w") as file: @@ -194,7 +196,7 @@ def save_adventure(self, file_path: str = None) -> str: logger.error(f"Failed to save adventure to {file_path}: {e}") raise - return file_path + return str(file_path) @staticmethod def load_adventure(file_path: str = None) -> "Adventure": diff --git a/osrlib/osrlib/constants.py b/osrlib/osrlib/constants.py new file mode 100644 index 0000000..78c335f --- /dev/null +++ b/osrlib/osrlib/constants.py @@ -0,0 +1,233 @@ +FIGHTER_NAMES = [ + "Alistair Goldleaf", + "Anya Steelgaze", + "Ariana Swiftarrow", + "Baldric Stormcaller", + "Brynn Thundershield", + "Caelum Silentblade", + "Cassius Blackthorn", + "Darius Skyclad", + "Elara Moonblade", + "Eldric the Bold", + "Evelyn Dawnseeker", + "Fenrir Sunward", + "Gareth Ironfist", + "Gwendolyn Wolfheart", + "Iris Whisperwind", + "Jorin Wildheart", + "Kaelen Darkwood", + "Kendrick Flamebearer", + "Leona Brightshield", + "Lysander Shadowwalker", + "Merek Stonecleaver", + "Nadia Nightbreeze", + "Orion Frostborne", + "Rowan Stormbringer", + "Seraphina Starlight", + "Sylvana Starforge", + "Thane Silverhelm", + "Tristan Runeblade", + "Varric Thunderhand", + "Zephyr Ironsoul", +] + + +ELF_NAMES = [ + "Adran Highforest", + "Aelar Galanodel", + "Arannis Starflower", + "Ariella Greenleaf", + "Caelum Sunarrow", + "Eiravel Xiloscient", + "Elora Duskwalker", + "Erevan Skyharrow", + "Faelar Silverfrond", + "Fenris Silversong", + "Galinndan Evenwood", + "Ilphelkiir Serpentsbane", + "Isilme Moonshadow", + "Lia Moonwhisper", + "Nuala Nightbreeze", + "Riveth Meliamne", + "Rolen Oakenshield", + "Sariel Dawnbrook", + "Theren Nailo", + "Ysora Brightstar", +] + + +CLERIC_NAMES = [ + "Alaric Peaceforged", + "Anastasia Brightsoul", + "Aurora Gracehand", + "Bran Hearthmender", + "Callista Faithsong", + "Cassian Dawnwhisper", + "Cedric Sageblessed", + "Damaris Spiritheart", + "Darius Lifebinder", + "Elian Lightbringer", + "Elowen Starhealer", + "Ezra Stormcaller", + "Finnian Soulkeeper", + "Gideon Sunward", + "Godric Holybrand", + "Ione Truthseeker", + "Kira Everlight", + "Lucius Ironfaith", + "Lydia Hearthward", + "Maeve Starwatcher", + "Mirabel Starfall", + "Niamh Sunsong", + "Orpheus Moonward", + "Raphael Ironprayer", + "Rowena Sageheart", + "Serena Peaceweaver", + "Silas Shadowbane", + "Sylvester Nightingale", + "Thalia Moonblessed", + "Tristram Lightshield", +] + + +DWARF_NAMES = [ + "Audhild Strongheart", + "Balin Ironhand", + "Borin Ironfist", + "Bram Coppervein", + "Dagnal Stormforge", + "Dorn Heavyhammer", + "Eberk Alestout", + "Elsa Axebrewer", + "Fargrim Earthshield", + "Greta Stonebreaker", + "Harbek Goldfinder", + "Helga Shieldmaiden", + "Hilda Thunderforge", + "Korbin Battlebeard", + "Magni Silveraxe", + "Orsik Flamebeard", + "Runa Mountainbraid", + "Sigrun Rockseeker", + "Tilda Ironfoot", + "Ulfgar Frostbeard", +] + + +THIEF_NAMES = [ + "Aria Moonmark", + "Bram Slipknot", + "Cade Thistleshank", + "Corbin Blackcloak", + "Dax Whisperwind", + "Elara Starthief", + "Eris Silentstep", + "Finn Sleight", + "Garrett Ironmask", + "Isla Ghoststep", + "Ivy Quickfoot", + "Jasper Nightlock", + "Lila Fleetshadow", + "Lorcan Shadowhand", + "Mara Softpocket", + "Milo Swiftfingers", + "Neve Shadowmere", + "Nyx Lightfinger", + "Rook Greygaze", + "Seline Darkwhisper", +] + + +HALFLING_NAMES = [ + "Bella Burrows", + "Bilbo Lightfoot", + "Bramble Brushgather", + "Bungo Thistleshanks", + "Daisy Highhill", + "Drogo Bramblethorn", + "Esmerelda Butterchurn", + "Finn Greenbottle", + "Lily Greenhill", + "Lobelia Applebloom", + "Meriadoc Brandybuck", + "Milo Underbough", + "Norah Knotwise", + "Perrin Goodbarrel", + "Pippin Hilltopple", + "Primrose Sandybanks", + "Rosie Hardbiscuit", + "Ruby Tealeaf", + "Samwise Fairfoot", + "Togo Weedwhacker", +] + + +MAGIC_USER_NAMES = [ + "Alaric Shadowcaster", + "Ariadne Crystalgaze", + "Athena Dreamweaver", + "Azriel Sunwhisper", + "Calista Starborn", + "Cedric Stormcaller", + "Circe Mysticmoon", + "Draven Nightseer", + "Emrys Voidwalker", + "Evelyn Moonsinger", + "Gaius Brightwand", + "Iliana Frostfire", + "Isolde Shadowspell", + "Luna Spellbinder", + "Lysandra Starweaver", + "Magnus Darkweaver", + "Orion Spellweaver", + "Seraphina Flameheart", + "Xander Highmage", + "Zephyrus Lightbringer", +] + + +ADVENTURE_NAMES = [ + "Ancient Wizard's Riddle", + "Arcane Shadows", + "Cursed Glade Expedition", + "Depths of Mysticism", + "Echoes of Magic", + "Enigma of Depths", + "Forbidden Arcana", + "Forgotten Citadel Journey", + "Glofarnux's Legacy", + "Glofarnux's Reckoning", + "Haunted Forest Trek", + "Hidden Realms", + "Lich's Awakening", + "Lost Glade Adventure", + "Mystic Forest Quest", + "Mystic Silence", + "Subterranean Secrets", + "Tale of the Mad Mage", + "Twilight of Glofarnux", + "Wizard's Lost Domain", +] + +DUNGEON_NAMES = [ + "Ancient Wizard's Hold", + "Arcane Underground", + "Cursed Labyrinth", + "Echoing Catacombs", + "Enchanted Chambers", + "Farnuxium Depths", + "Forbidden Undercroft", + "Forest Heart Dungeon", + "Glofarnux's Bastion", + "Glofarnux's Chambers", + "Glofarnux's Tomb", + "Lich's Hidden Stronghold", + "Lost Wizard's Domain", + "Mad Mage's Lair", + "Mystic Grotto", + "Mystic Hollows", + "Mystic Maze", + "Secluded Wizard's Keep", + "Shadowed Halls", + "Veiled Sanctum", +] diff --git a/osrlib/osrlib/dungeon.py b/osrlib/osrlib/dungeon.py index 538f2d7..0c135e1 100644 --- a/osrlib/osrlib/dungeon.py +++ b/osrlib/osrlib/dungeon.py @@ -250,37 +250,27 @@ def __init__( self.description = description self.locations = locations self.start_location_id = start_location_id - if start_location_id is not None: - self.set_start_location(start_location_id) # Also sets self.current_location - else: - self.current_location = self.locations[0] if len(self.locations) > 0 else None + self.current_location = None + self.set_start_location(start_location_id) # Also sets self.current_location self.id = id def set_start_location(self, location_id: int) -> Location: - """Sets the starting location for the dungeon. Also sets the current location to the starting location. + """Sets the starting location for the dungeon and also sets the current location to the starting location. Args: location_id (int): The ID of the location to set as the starting location. Returns: - Location: The starting location. - - Raises: - LocationNotFoundError: If the location ID does not exist in the dungeon. + Location: The starting location if it exists, otherwise None. """ logger.debug(f"Setting starting location to location with ID {location_id}.") - try: - start_location = [loc for loc in self.locations if loc.id == location_id][0] + start_location = self.get_location_by_id(location_id) + if start_location: self.current_location = start_location logger.debug(f"Starting location set to {start_location}.") return start_location - except IndexError: - logger.exception( - f"Location with ID {location_id} does not exist in the dungeon." - ) - raise LocationNotFoundError( - f"Location with ID {location_id} does not exist in the dungeon." - ) + else: + return None def add_location(self, location: Location) -> None: """Adds a location to the dungeon. @@ -298,18 +288,38 @@ def add_location(self, location: Location) -> None: raise exception def get_location_by_id(self, location_id: int) -> Location: - """Returns the location with the specified ID. + """Get the location with the specified ID. Args: location_id (int): The ID of the location to return. Returns: - Location: The location with the specified ID, otherwise None if the location with that ID doesn't exist. + Location: The location with the specified ID if it exists, otherwise None. """ return next((loc for loc in self.locations if loc.id == location_id), None) + def get_location_by_direction(self, location: Location, direction: Direction) -> Location: + """Get the location in the specified direction from the given location. + + Args: + location (Location): The location containing the exit whose destination should be returned. + direction (Direction): The direction of the give location's exit whose destination should be returned. + + Returns: + Location: The location that is the destination of the exit in the specified direction, otherwise None if there is no exit in that direction. + """ + exit = location.get_exit(direction) + if exit: + return self.get_location_by_id(exit.destination) + else: + return None + def move(self, direction: Direction) -> Location: - """Moves the party to the location in the specified direction if there's an exit in that direction. + """Moves the party to the location in the specified direction if there's an exit in that direction, and sets the + dungeon's current location to the new location. + + You should set the new location's ``is_visited`` property to True (if it was false) after you've done any + processing required when the party enters the location for the first time. Example: >>> exit1 = Exit(Direction.NORTH, 2) @@ -319,41 +329,42 @@ def move(self, direction: Direction) -> Location: >>> dungeon = Dungeon("Example Dungeon", "An example dungeon.", [location1, location2]) >>> start_location = dungeon.set_start_location(1) >>> new_location = dungeon.move(Direction.NORTH) - >>> dungeon.current_location.id == location2.id + >>> if new_location: + ... if new_location.is_visited: + ... print(f"Party moved to previously visited location {new_location}.") + ... else: + ... print(f"Party moved to new location {new_location}.") + ... new_location.is_visited = True + Party moved to new location LOC ID: 2 Size: 10'W x 10'L Exits: [NORTH:1] Keywords: ['rust', 'armory'] + >>> dungeon.current_location == new_location True Args: direction (Direction): The direction of the exit the party should move through. Returns: - Location: The location the party moved to, or None if there is no exit in the specified direction. + Location: The location the party moved to if they were able to move in the specified direction, otherwise None. """ - logger.debug(f"Moving party {direction.name} from {self.current_location}.") - try: - exit = [ - exit - for exit in self.current_location.exits - if exit.direction == direction - ][0] - except IndexError: + new_location = self.get_location_by_direction(self.current_location, direction) + if not new_location: logger.debug( - f"No exit to the {direction.name} from {self.current_location}. The only exits are: " + f"No location {direction.name} of {self.current_location}. The only exits are: " + ", ".join(str(exit) for exit in self.current_location.exits) + "." ) return None - self.current_location = [ - loc for loc in self.locations if loc.id == exit.destination - ][0] + self.current_location = new_location + # NOTE: We do NOT set is_visited to True here because we need to give the caller the opportunity to check and + # perform any actions that need to be done when the party enters a location for the first time. They can then + # set is_visited to True when they're done performing those actions. if self.current_location.is_visited: logger.debug( - f"Party moved to previously visited location {self.current_location}." + f"Party moved to previously visited (is_visited = True) location {self.current_location}." ) else: - logger.debug(f"Party moved to new location {self.current_location}.") - self.current_location.is_visited = True + logger.debug(f"Party moved to new (is_visited = False) location {self.current_location}.") return self.current_location diff --git a/osrlib/osrlib/dungeon_master.py b/osrlib/osrlib/dungeon_master.py index 73a7b8b..bbfb171 100644 --- a/osrlib/osrlib/dungeon_master.py +++ b/osrlib/osrlib/dungeon_master.py @@ -157,6 +157,7 @@ def start_session(self): return completion.choices[0].message.content def player_message(self, message): + """Send a message from the player to the Dungeon Master and return the response.""" if self.is_started: self.session_messages.append(message) completion = self.client.chat.completions.create( @@ -167,12 +168,14 @@ def player_message(self, message): return completion.choices[0].message.content def move_party(self, direction) -> str: + """Move the party in the given direction.""" new_location = self.adventure.active_dungeon.move(direction) - # new_location.is_visited = False # BUG: This is a hack to force the DM to describe the location again. if new_location is None: return "No exit in that direction." message_from_player = self.format_user_message(user_move_prefix + new_location.json) - return self.player_message(message_from_player) + dm_response = self.player_message(message_from_player) + new_location.is_visited = True + return dm_response def summarize_battle(self, battle_log) -> str: message_from_player = self.format_user_message(battle_summary_prompt + battle_log) diff --git a/osrlib/osrlib/party.py b/osrlib/osrlib/party.py index 626c0d9..4f9279f 100644 --- a/osrlib/osrlib/party.py +++ b/osrlib/osrlib/party.py @@ -1,6 +1,7 @@ """The Party module contains the Party class and functions related to managing a party of player characters (collection of type PlayerCharacter).""" +import random from typing import List from osrlib.player_character import PlayerCharacter @@ -8,7 +9,15 @@ from osrlib.enums import CharacterClassType from osrlib.item_factories import equip_party from osrlib.dice_roller import roll_dice -import time +from osrlib.constants import ( + FIGHTER_NAMES, + ELF_NAMES, + CLERIC_NAMES, + DWARF_NAMES, + THIEF_NAMES, + HALFLING_NAMES, + MAGIC_USER_NAMES +) class PartyAtCapacityError(Exception): """Raised when attempting to add a player character to a party that already has the maximum number of members.""" @@ -81,7 +90,7 @@ class Party: def __init__( self, name: str, - max_party_members: int = 6, + max_party_members: int = 8, characters: List[PlayerCharacter] = None, ): """Initialize a new Party instance.""" @@ -349,6 +358,28 @@ def get_character_by_index(self, index: int) -> PlayerCharacter: except IndexError: return None + def get_characters_by_class(self, character_class_type: CharacterClassType) -> List[PlayerCharacter]: + """Get all characters in the party of the given class. + + Example: + + .. code-block:: python + fighters = party.get_characters_by_class(character_classes.CharacterClassType.FIGHTER) + for fighter in fighters: + print(fighter.name) + + Args: + character_class (character_classes.CharacterClassType): The class of characters to return. + + Returns: + List[PlayerCharacter]: A list of all characters in the party of the given class. + """ + return [ + character + for character in self.members + if character.character_class.class_type == character_class_type + ] + def get_character_index(self, character: PlayerCharacter) -> int: """Get the index of a character in the party. @@ -486,12 +517,13 @@ def get_default_party(party_name: str = "Default Party") -> Party: # pragma: no Party: A party with six (6) player characters at first level (zero experience points). """ party = Party(party_name) - party.create_character("Sckricko", CharacterClassType.FIGHTER, 1) - party.create_character("Mazpar", CharacterClassType.ELF, 1) - party.create_character("Blarg The Destructor", CharacterClassType.DWARF, 1) - party.create_character("Slick", CharacterClassType.THIEF, 1) - party.create_character("Dimp Beefeeder", CharacterClassType.HALFLING, 1) - party.create_character("Merlin", CharacterClassType.MAGIC_USER, 1) + party.create_character(random.choice(FIGHTER_NAMES), CharacterClassType.FIGHTER, 1) + party.create_character(random.choice(ELF_NAMES), CharacterClassType.ELF, 1) + party.create_character(random.choice(CLERIC_NAMES), CharacterClassType.CLERIC, 1) + party.create_character(random.choice(DWARF_NAMES), CharacterClassType.DWARF, 1) + party.create_character(random.choice(THIEF_NAMES), CharacterClassType.THIEF, 1) + party.create_character(random.choice(HALFLING_NAMES), CharacterClassType.HALFLING, 1) + party.create_character(random.choice(MAGIC_USER_NAMES), CharacterClassType.MAGIC_USER, 1) equip_party(party) diff --git a/osrlib/osrlib/utils.py b/osrlib/osrlib/utils.py index c66b3a7..cdec5ae 100644 --- a/osrlib/osrlib/utils.py +++ b/osrlib/osrlib/utils.py @@ -1,5 +1,7 @@ -from osrlib import ModifierType -import textwrap +import os, platform, textwrap +from pathlib import Path + +from osrlib.ability import ModifierType def format_modifiers(modifiers: dict[ModifierType, int]) -> str: @@ -19,8 +21,136 @@ def format_modifiers(modifiers: dict[ModifierType, int]) -> str: Returns: A comma-delimited string representation of the modifiers. """ - return ", ".join(f"{mod.value}: {'+' if val > 0 else ''}{val}" for mod, val in modifiers.items()) + return ", ".join( + f"{mod.value}: {'+' if val > 0 else ''}{val}" for mod, val in modifiers.items() + ) + + +def wrap_text(text: str, width: int = 100) -> str: + """ + Wrap a given string of text to a specified width. + + This function utilizes the `textwrap` module to break a long string of text into lines of + a given width. It's particularly useful for formatting long strings into a more readable + form, especially in user interfaces where space is limited. + + Args: + text (str): The text string to be wrapped. + width (int, optional): The maximum width of the wrapped lines. Defaults to 100 characters. + + Returns: + str: A new string with the original text wrapped to the specified width. + + Example: + >>> sample_text = "This is a long text string that needs to be wrapped for better readability." + >>> print(wrap_text(sample_text, 40)) + This is a long text string that needs + to be wrapped for better readability. + """ + return textwrap.fill(text, width) + + +import re + +# import unicodedata # Uncomment if you need Unicode normalization + + +def sanitize_path_element(path_element: str, replace_space: str = "_") -> str: + """ + Sanitize a string to ensure it's a valid path element for file and directory names. + + This function removes or replaces characters that are not allowed in file and directory names + on various operating systems. Use this function to sanitize any part of a file path. + + Args: + path_element (str): The path element (e.g., file name, directory name) to sanitize. + replace_space (str, optional): The character to replace spaces with. Defaults to '_'. + Set to None to remove spaces instead. + + Returns: + str: The sanitized path element. + + Example: + >>> sanitized_name = sanitize_path_element("Invalid/Name*<>?") + >>> print(sanitized_name) + InvalidName + """ + # Remove invalid characters + sanitized = re.sub(r'[<>:"/\\|?*]', "", path_element) + + # Replace or remove spaces + if replace_space is not None: + sanitized = sanitized.replace(" ", replace_space) + else: + sanitized = sanitized.replace(" ", "") + + # Limit length (optional, here 255 characters as an example) + sanitized = sanitized[:255] + + return sanitized + + +def create_dir_tree_if_not_exist(directory: Path): + """ + Create the entire directory tree if it does not already exist. + + This function checks if the specified directory exists, and if not, it creates not only + the directory but also any necessary parent directories. It's useful for ensuring that + the entire path structure is ready for file operations. + + Args: + directory (Path): The Path object representing the directory to check and create. + + Raises: + OSError: If the directory cannot be created due to permissions or other file system errors. + """ + if not directory.exists(): + try: + directory.mkdir(parents=True, exist_ok=True) + except OSError as e: + raise OSError(f"Unable to create directory tree {directory}: {e}") + + +def get_data_dir_path(app_name: str) -> Path: + """ + Determine the appropriate directory for saving game data based on the operating system. + + This function dynamically identifies the standard directory for storing application data + on different platforms (Windows, macOS, Linux/Unix-like). On Windows, it uses the + 'LOCALAPPDATA' environment variable to find the local application data folder. On macOS, + it defaults to the 'Library/Application Support' directory within the user's home directory. + For Linux and Unix-like systems, it uses the 'XDG_DATA_HOME' environment variable, defaulting + to '.local/share' in the user's home directory if the variable is not set. + + Args: + app_name (str): The name of the application. Used to create a subdirectory within the standard + application data directory. + + Returns: + Path: An object representing the absolute path to the directory where game save data + should be stored. + Raises: + ValueError: If the operating system is not supported (not Windows, macOS, or Linux/Unix-like). + + Example: + >>> game_save_dir = get_save_game_directory("YourAppName") + >>> print(game_save_dir) + /home/username/.local/share/YourAppName # On Linux + C:\\Users\\username\\AppData\\Local\\YourAppName # On Windows + /Users/username/Library/Application Support/YourAppName # On macOS + """ + system = os.name + if system == "nt": # Windows + base_dir = Path(os.environ.get("LOCALAPPDATA", "")) + elif system == "posix": + if platform.system() == "Darwin": # macOS + base_dir = Path.home() / "Library" / "Application Support" + else: # Linux and Unix-like + base_dir = Path( + os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share") + ) + else: + raise ValueError("Unsupported operating system.") -def wrap_text(text, width=100): - return textwrap.fill(text, width) \ No newline at end of file + return base_dir / sanitize_path_element(app_name) diff --git a/osrlib/poetry.lock b/osrlib/poetry.lock index 7f52bd0..baf69e1 100644 --- a/osrlib/poetry.lock +++ b/osrlib/poetry.lock @@ -380,19 +380,19 @@ trio = ["trio (>=0.22.0,<0.23.0)"] [[package]] name = "httpx" -version = "0.25.1" +version = "0.25.2" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" files = [ - {file = "httpx-0.25.1-py3-none-any.whl", hash = "sha256:fec7d6cc5c27c578a391f7e87b9aa7d3d8fbcd034f6399f9f79b45bcc12a866a"}, - {file = "httpx-0.25.1.tar.gz", hash = "sha256:ffd96d5cf901e63863d9f1b4b6807861dbea4d301613415d9e6e57ead15fc5d0"}, + {file = "httpx-0.25.2-py3-none-any.whl", hash = "sha256:a05d3d052d9b2dfce0e3896636467f8a5342fb2b902c819428e1ac65413ca118"}, + {file = "httpx-0.25.2.tar.gz", hash = "sha256:8b8fcaa0c8ea7b05edd69a094e63a2094c4efcb48129fb757361bc423c0ad9e8"}, ] [package.dependencies] anyio = "*" certifi = "*" -httpcore = "*" +httpcore = "==1.*" idna = "*" sniffio = "*" @@ -404,13 +404,13 @@ socks = ["socksio (==1.*)"] [[package]] name = "idna" -version = "3.4" +version = "3.6" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.5" files = [ - {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, - {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, + {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"}, + {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, ] [[package]] @@ -579,13 +579,13 @@ min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4)", "ghp-imp [[package]] name = "mkdocs-material" -version = "9.4.11" +version = "9.4.14" description = "Documentation that simply works" optional = false python-versions = ">=3.8" files = [ - {file = "mkdocs_material-9.4.11-py3-none-any.whl", hash = "sha256:794b81d74df4fd7dee952dd4502f7b6a7913a1fc56021e5f36f8e96eb20ffb25"}, - {file = "mkdocs_material-9.4.11.tar.gz", hash = "sha256:82c2bdbdc8445854f400d12831a8b0f7602efaaead7b264ac3c45aa3aa240755"}, + {file = "mkdocs_material-9.4.14-py3-none-any.whl", hash = "sha256:dbc78a4fea97b74319a6aa9a2f0be575a6028be6958f813ba367188f7b8428f6"}, + {file = "mkdocs_material-9.4.14.tar.gz", hash = "sha256:a511d3ff48fa8718b033e7e37d17abd9cc1de0fdf0244a625ca2ae2387e2416d"}, ] [package.dependencies] @@ -630,13 +630,13 @@ files = [ [[package]] name = "openai" -version = "1.3.5" +version = "1.3.6" description = "The official Python library for the openai API" optional = false python-versions = ">=3.7.1" files = [ - {file = "openai-1.3.5-py3-none-any.whl", hash = "sha256:9437458978fb502e61336c3082e02b09c49feebe0e8516a2b8fb4563e6e4af4e"}, - {file = "openai-1.3.5.tar.gz", hash = "sha256:163e7ece4af76e961f58b75ea20a42b0d0c2a240c2f81b41a3d1c5962463cdf8"}, + {file = "openai-1.3.6-py3-none-any.whl", hash = "sha256:a88063de50706e7a25d9d3dcc0aa4b7e1f59ad40a3b8901af28fec097abeb230"}, + {file = "openai-1.3.6.tar.gz", hash = "sha256:5f1f00658474668d8ecb7e87ceed101632c66e1e57d29aa9e0b1cb21f65a72be"}, ] [package.dependencies] @@ -644,6 +644,7 @@ anyio = ">=3.5.0,<4" distro = ">=1.7.0,<2" httpx = ">=0.23.0,<1" pydantic = ">=1.9.0,<3" +sniffio = "*" tqdm = ">4" typing-extensions = ">=4.5,<5" @@ -904,17 +905,17 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pymdown-extensions" -version = "10.4" +version = "10.5" description = "Extension pack for Python Markdown." optional = false python-versions = ">=3.8" files = [ - {file = "pymdown_extensions-10.4-py3-none-any.whl", hash = "sha256:cfc28d6a09d19448bcbf8eee3ce098c7d17ff99f7bd3069db4819af181212037"}, - {file = "pymdown_extensions-10.4.tar.gz", hash = "sha256:bc46f11749ecd4d6b71cf62396104b4a200bad3498cb0f5dad1b8502fe461a35"}, + {file = "pymdown_extensions-10.5-py3-none-any.whl", hash = "sha256:1f0ca8bb5beff091315f793ee17683bc1390731f6ac4c5eb01e27464b80fe879"}, + {file = "pymdown_extensions-10.5.tar.gz", hash = "sha256:1b60f1e462adbec5a1ed79dac91f666c9c0d241fa294de1989f29d20096cfd0b"}, ] [package.dependencies] -markdown = ">=3.2" +markdown = ">=3.5" pyyaml = "*" [package.extras] diff --git a/osrlib/pyproject.toml b/osrlib/pyproject.toml index 8f467b7..1faaf47 100644 --- a/osrlib/pyproject.toml +++ b/osrlib/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "osrlib" -version = "0.1.27" +version = "0.1.44" description = "Turn-based dungeon-crawler game engine for OSR-style RPGs." authors = ["Marsh Macy "] license = "MIT" diff --git a/tests/poetry.lock b/tests/poetry.lock index 773c1ec..e76ffdc 100644 --- a/tests/poetry.lock +++ b/tests/poetry.lock @@ -144,13 +144,13 @@ files = [ [[package]] name = "openai" -version = "1.3.5" +version = "1.3.6" description = "The official Python library for the openai API" optional = false python-versions = ">=3.7.1" files = [ - {file = "openai-1.3.5-py3-none-any.whl", hash = "sha256:9437458978fb502e61336c3082e02b09c49feebe0e8516a2b8fb4563e6e4af4e"}, - {file = "openai-1.3.5.tar.gz", hash = "sha256:163e7ece4af76e961f58b75ea20a42b0d0c2a240c2f81b41a3d1c5962463cdf8"}, + {file = "openai-1.3.6-py3-none-any.whl", hash = "sha256:a88063de50706e7a25d9d3dcc0aa4b7e1f59ad40a3b8901af28fec097abeb230"}, + {file = "openai-1.3.6.tar.gz", hash = "sha256:5f1f00658474668d8ecb7e87ceed101632c66e1e57d29aa9e0b1cb21f65a72be"}, ] [package.dependencies] @@ -158,6 +158,7 @@ anyio = ">=3.5.0,<4" distro = ">=1.7.0,<2" httpx = ">=0.23.0,<1" pydantic = ">=1.9.0,<3" +sniffio = "*" tqdm = ">4" typing-extensions = ">=4.5,<5" @@ -166,7 +167,7 @@ datalib = ["numpy (>=1)", "pandas (>=1.2.3)", "pandas-stubs (>=1.1.0.11)"] [[package]] name = "osrlib" -version = "0.1.14" +version = "0.1.44" description = "Turn-based dungeon-crawler game engine for OSR-style RPGs." optional = false python-versions = "^3.11" diff --git a/tests/test_integration_saveload.py b/tests/test_integration_saveload.py index 235a97c..cc5a7ba 100644 --- a/tests/test_integration_saveload.py +++ b/tests/test_integration_saveload.py @@ -2,14 +2,11 @@ import pytest from tinydb import Query, TinyDB -from osrlib import ( - Party, - PlayerCharacter, - item, - character_classes, - party, - game_manager as gm, -) +from osrlib.party import Party, get_default_party +from osrlib.player_character import PlayerCharacter +from osrlib.item import Item, ItemType, ItemNotUsableError, Armor, Weapon, Spell +from osrlib.character_classes import CharacterClassType +from osrlib.game_manager import logger from osrlib.ability import ( Strength, Intelligence, @@ -29,14 +26,14 @@ @pytest.fixture def test_fighter(): - pc = PlayerCharacter("Test Fighter", character_classes.CharacterClassType.FIGHTER) + pc = PlayerCharacter("Test Fighter", CharacterClassType.FIGHTER) yield pc pc.inventory.drop_all_items() @pytest.fixture def test_elf(): - pc = PlayerCharacter("Test Elf", character_classes.CharacterClassType.ELF) + pc = PlayerCharacter("Test Elf", CharacterClassType.ELF) yield pc pc.inventory.drop_all_items() @@ -44,7 +41,7 @@ def test_elf(): @pytest.fixture def test_magic_user(): pc = PlayerCharacter( - "Test Magic User", character_classes.CharacterClassType.MAGIC_USER + "Test Magic User", CharacterClassType.MAGIC_USER ) yield pc pc.inventory.drop_all_items() @@ -54,10 +51,10 @@ def test_magic_user(): def db(): db_path = "test_db.json" full_path = os.path.abspath(db_path) - gm.logger.debug(f"Setting up TinyDB: {full_path}") + logger.debug(f"Setting up TinyDB: {full_path}") db = TinyDB(db_path) yield db - gm.logger.debug(f"Tearing down TinyDB: {db}") + logger.debug(f"Tearing down TinyDB: {db}") db.drop_tables() db.close() @@ -135,11 +132,11 @@ def test_item_saveload(db): # Create an Item instance usable_by = { - character_classes.CharacterClassType.FIGHTER, - character_classes.CharacterClassType.THIEF, + CharacterClassType.FIGHTER, + CharacterClassType.THIEF, } - original_item = item.Item( - "50' rope", item.ItemType.ITEM, usable_by, max_equipped=0, gp_value=5 + original_item = Item( + "50' rope", ItemType.ITEM, usable_by, max_equipped=0, gp_value=5 ) # Serialize and insert into DB @@ -149,7 +146,7 @@ def test_item_saveload(db): # Retrieve and deserialize ItemQuery = Query() retrieved_item_dict = item_table.search(ItemQuery.name == "50' rope")[0] - retrieved_item = item.Item.from_dict(retrieved_item_dict) + retrieved_item = Item.from_dict(retrieved_item_dict) # Assertions to check whether deserialization was correct assert original_item.name == retrieved_item.name @@ -164,10 +161,10 @@ def test_armor_saveload(db): # Create an Item instance usable_by = { - character_classes.CharacterClassType.FIGHTER, - character_classes.CharacterClassType.ELF, + CharacterClassType.FIGHTER, + CharacterClassType.ELF, } - original_item = item.Armor( + original_item = Armor( "Chain Mail", -4, usable_by_classes=usable_by, max_equipped=1, gp_value=40 ) @@ -178,10 +175,10 @@ def test_armor_saveload(db): # Retrieve and deserialize ItemQuery = Query() retrieved_item_dict = item_table.search(ItemQuery.name == "Chain Mail")[0] - retrieved_item = item.Armor.from_dict(retrieved_item_dict) + retrieved_item = Armor.from_dict(retrieved_item_dict) # Assertions to check whether deserialization was correct - assert retrieved_item.item_type == item.ItemType.ARMOR + assert retrieved_item.item_type == ItemType.ARMOR assert original_item.name == retrieved_item.name assert original_item.item_type == retrieved_item.item_type assert original_item.usable_by_classes == retrieved_item.usable_by_classes @@ -195,10 +192,10 @@ def test_weapon_saveload(db, test_fighter, test_elf, test_magic_user): # Create an Item instance usable_by = { - character_classes.CharacterClassType.FIGHTER, - character_classes.CharacterClassType.ELF, + CharacterClassType.FIGHTER, + CharacterClassType.ELF, } - original_sword = item.Weapon( + original_sword = Weapon( "Sword", to_hit_damage_die="1d8", usable_by_classes=usable_by, @@ -213,10 +210,10 @@ def test_weapon_saveload(db, test_fighter, test_elf, test_magic_user): # Retrieve and deserialize ItemQuery = Query() retrieved_sword_dict = item_table.search(ItemQuery.name == "Sword")[0] - retrieved_sword = item.Weapon.from_dict(retrieved_sword_dict) + retrieved_sword = Weapon.from_dict(retrieved_sword_dict) # Assertions to check whether deserialization was correct - assert retrieved_sword.item_type == item.ItemType.WEAPON + assert retrieved_sword.item_type == ItemType.WEAPON assert original_sword.name == retrieved_sword.name assert original_sword.item_type == retrieved_sword.item_type assert original_sword.usable_by_classes == retrieved_sword.usable_by_classes @@ -245,8 +242,8 @@ def test_weapon_saveload(db, test_fighter, test_elf, test_magic_user): test_magic_user.inventory.add_item(retrieved_sword) try: test_magic_user.inventory.equip_item(retrieved_sword) - except item.ItemNotUsableError as e: - assert isinstance(e, item.ItemNotUsableError) + except ItemNotUsableError as e: + assert isinstance(e, ItemNotUsableError) test_magic_user.inventory.remove_item(retrieved_sword) @@ -254,8 +251,8 @@ def test_spell_saveload(db, test_fighter, test_magic_user): item_table = db.table("spell") # Create a Spell instance - usable_by = {character_classes.CharacterClassType.MAGIC_USER} - original_spell = item.Spell( + usable_by = {CharacterClassType.MAGIC_USER} + original_spell = Spell( "Fireball", spell_level=3, damage_die="8d6", @@ -273,10 +270,10 @@ def test_spell_saveload(db, test_fighter, test_magic_user): # Retrieve and deserialize ItemQuery = Query() retrieved_spell_dict = item_table.search(ItemQuery.name == "Fireball")[0] - retrieved_spell = item.Spell.from_dict(retrieved_spell_dict) + retrieved_spell = Spell.from_dict(retrieved_spell_dict) # Assertions to check whether deserialization was correct - assert retrieved_spell.item_type == item.ItemType.SPELL + assert retrieved_spell.item_type == ItemType.SPELL assert original_spell.name == retrieved_spell.name assert original_spell.item_type == retrieved_spell.item_type assert original_spell.usable_by_classes == retrieved_spell.usable_by_classes @@ -299,8 +296,8 @@ def test_spell_saveload(db, test_fighter, test_magic_user): try: test_fighter.inventory.add_item(retrieved_spell) test_fighter.inventory.equip_item(retrieved_spell) - except item.ItemNotUsableError as e: - assert isinstance(e, item.ItemNotUsableError) + except ItemNotUsableError as e: + assert isinstance(e, ItemNotUsableError) test_fighter.inventory.remove_item(retrieved_spell) @@ -318,21 +315,21 @@ def test_item_autoset_attributes_preserved_on_saveload(db, test_fighter): item_table = db.table("item") # Step 1: Create instances - armor = item.Armor( + armor = Armor( "Plate Mail Armor", gp_value=50, max_equipped=1, - usable_by_classes={character_classes.CharacterClassType.FIGHTER}, + usable_by_classes={CharacterClassType.FIGHTER}, ) - weapon = item.Weapon( + weapon = Weapon( "Sword", "1d8", gp_value=30, max_equipped=1, - usable_by_classes={character_classes.CharacterClassType.FIGHTER}, + usable_by_classes={CharacterClassType.FIGHTER}, ) - normal_item = item.Item( - "50' rope", item.ItemType.EQUIPMENT, gp_value=1, max_equipped=0 + normal_item = Item( + "50' rope", ItemType.EQUIPMENT, gp_value=1, max_equipped=0 ) # Step 2: Add to test_fighter's inventory @@ -367,9 +364,9 @@ def test_item_autoset_attributes_preserved_on_saveload(db, test_fighter): retrieved_weapon_dict = item_table.search(ItemQuery.name == "Sword")[0] retrieved_normal_item_dict = item_table.search(ItemQuery.name == "50' rope")[0] - retrieved_armor = item.Armor.from_dict(retrieved_armor_dict) - retrieved_weapon = item.Weapon.from_dict(retrieved_weapon_dict) - retrieved_normal_item = item.Item.from_dict(retrieved_normal_item_dict) + retrieved_armor = Armor.from_dict(retrieved_armor_dict) + retrieved_weapon = Weapon.from_dict(retrieved_weapon_dict) + retrieved_normal_item = Item.from_dict(retrieved_normal_item_dict) # Step 6: Set the item's owner and equip previously equipped items. # This is necessary because the owner attribute is not serialized and the @@ -395,22 +392,22 @@ def test_item_autoset_attributes_preserved_on_saveload(db, test_fighter): def test_inventory_saveload(db, test_fighter): inventory_table = db.table("inventory") - armor = item.Armor( + armor = Armor( "Plate Mail Armor", gp_value=50, max_equipped=1, ac_modifier=-6, - usable_by_classes={character_classes.CharacterClassType.FIGHTER}, + usable_by_classes={CharacterClassType.FIGHTER}, ) - weapon = item.Weapon( + weapon = Weapon( "Sword", "1d8", gp_value=30, max_equipped=1, - usable_by_classes={character_classes.CharacterClassType.FIGHTER}, + usable_by_classes={CharacterClassType.FIGHTER}, ) - normal_item = item.Item( - "50' rope", item.ItemType.EQUIPMENT, gp_value=1, max_equipped=0 + normal_item = Item( + "50' rope", ItemType.EQUIPMENT, gp_value=1, max_equipped=0 ) test_fighter.inventory.add_item(armor) @@ -425,7 +422,7 @@ def test_inventory_saveload(db, test_fighter): # Simulate a new or loaded character test_fighter_loaded_from_db = PlayerCharacter( - "Fighter From DB", character_classes.CharacterClassType.FIGHTER + "Fighter From DB", CharacterClassType.FIGHTER ) # LOAD the inventory from the DB @@ -484,22 +481,22 @@ def test_inventory_saveload(db, test_fighter): def test_player_character_saveload(db, test_fighter): # Saving a PlayerCharacter pc = test_fighter - armor = item.Armor( + armor = Armor( "Plate Mail Armor", gp_value=50, max_equipped=1, ac_modifier=-6, - usable_by_classes={character_classes.CharacterClassType.FIGHTER}, + usable_by_classes={CharacterClassType.FIGHTER}, ) - weapon = item.Weapon( + weapon = Weapon( "Sword", "1d8", gp_value=30, max_equipped=1, - usable_by_classes={character_classes.CharacterClassType.FIGHTER}, + usable_by_classes={CharacterClassType.FIGHTER}, ) - normal_item = item.Item( - "50' rope", item.ItemType.EQUIPMENT, gp_value=1, max_equipped=0 + normal_item = Item( + "50' rope", ItemType.EQUIPMENT, gp_value=1, max_equipped=0 ) pc.inventory.add_item(armor) pc.inventory.add_item(weapon) @@ -508,7 +505,7 @@ def test_player_character_saveload(db, test_fighter): pc.inventory.equip_item(weapon) # SAVE the PC - gm.logger.debug(f"Saving PC: {pc}") + logger.debug(f"Saving PC: {pc}") pc_table = db.table("player_characters") pc_dict = pc.to_dict() pc_table.insert(pc_dict) @@ -517,7 +514,7 @@ def test_player_character_saveload(db, test_fighter): PCQuery = Query() loaded_pc_dict = pc_table.search(PCQuery.name == pc.name)[0] loaded_pc = PlayerCharacter.from_dict(loaded_pc_dict) - gm.logger.debug(f"Loaded PC: {loaded_pc}") + logger.debug(f"Loaded PC: {loaded_pc}") assert str(loaded_pc) == str(pc) assert loaded_pc.inventory.items.keys() == pc.inventory.items.keys() @@ -525,33 +522,33 @@ def test_player_character_saveload(db, test_fighter): def test_party_saveload(db): - pc_party = party.get_default_party() + pc_party = get_default_party() # Give one party member some gear - armor = item.Armor( + armor = Armor( "Plate Mail Armor", gp_value=50, max_equipped=1, ac_modifier=-6, usable_by_classes={ - character_classes.CharacterClassType.FIGHTER, - character_classes.CharacterClassType.ELF, + CharacterClassType.FIGHTER, + CharacterClassType.ELF, }, ) - weapon = item.Weapon( + weapon = Weapon( "Sword", "1d8", gp_value=30, max_equipped=1, usable_by_classes={ - character_classes.CharacterClassType.FIGHTER, - character_classes.CharacterClassType.ELF, + CharacterClassType.FIGHTER, + CharacterClassType.ELF, }, ) - normal_item = item.Item( - "50' rope", item.ItemType.EQUIPMENT, gp_value=1, max_equipped=0 + normal_item = Item( + "50' rope", ItemType.EQUIPMENT, gp_value=1, max_equipped=0 ) - elf = pc_party.get_character_by_name("Mazpar") + elf = pc_party.get_characters_by_class(CharacterClassType.ELF)[0] elf.inventory.add_item(armor) elf.inventory.add_item(weapon) elf.inventory.add_item(normal_item) @@ -559,7 +556,7 @@ def test_party_saveload(db): elf.inventory.equip_item(weapon) # SAVE the party - gm.logger.debug( + logger.debug( f"Saving party {pc_party.name} with {pc_party.num_characters} characters..." ) party_table = db.table("player_characters") @@ -569,7 +566,7 @@ def test_party_saveload(db): assert doc_id == 1 # LOAD the party - gm.logger.debug(f"Loading party {pc_party.name}...") + logger.debug(f"Loading party {pc_party.name}...") PartyQuery = Query() fetched_party_dicts = party_table.search(PartyQuery.name == pc_party.name) assert len(fetched_party_dicts) == 1 @@ -577,7 +574,7 @@ def test_party_saveload(db): # Deserialize and create a Party object from the fetched dictionary loaded_party = Party.from_dict(fetched_party_dict) - gm.logger.debug(f"Loaded party:\n{loaded_party}") + logger.debug(f"Loaded party:\n{loaded_party}") # Verify that the loaded Party is the same as the original assert loaded_party.name == pc_party.name @@ -594,14 +591,14 @@ def test_party_saveload(db): # Test weather a "dead" party stays dead after saving and loading def test_party_saveload_dead(db): - pc_party = party.get_default_party() + pc_party = get_default_party() # Kill the party for character in pc_party.members: character.apply_damage(character.character_class.max_hp) # Write the party to storage - gm.logger.debug( + logger.debug( f"Saving party {pc_party.name} with {pc_party.num_characters} characters..." ) party_table = db.table("player_characters") @@ -611,7 +608,7 @@ def test_party_saveload_dead(db): assert doc_id == 1 # LOAD the party from storage - gm.logger.debug(f"Loading party {pc_party.name}...") + logger.debug(f"Loading party {pc_party.name}...") PartyQuery = Query() fetched_party_dicts = party_table.search(PartyQuery.name == pc_party.name) assert len(fetched_party_dicts) == 1 @@ -619,7 +616,7 @@ def test_party_saveload_dead(db): # Deserialize and create a Party object from the fetched dictionary loaded_party = Party.from_dict(fetched_party_dict) - gm.logger.debug(f"Loaded party:\n{loaded_party}") + logger.debug(f"Loaded party:\n{loaded_party}") # Verify that the loaded Party is still dead (all party members have 0 HP) assert not loaded_party.is_alive @@ -650,6 +647,7 @@ def test_location_save_load(db): def test_dungeon_save_load(db): location = Location(1, 10, 10, [Exit(Direction.NORTH, 2)]) dungeon = Dungeon("TestDungeon", "An example dungeon.", [location]) + dungeon.set_start_location(1) data = dungeon.to_dict() db.insert(data) loaded_data = db.all()[0] diff --git a/tests/test_unit_dungeon.py b/tests/test_unit_dungeon.py index 0cf799c..8b6a3bf 100644 --- a/tests/test_unit_dungeon.py +++ b/tests/test_unit_dungeon.py @@ -110,8 +110,7 @@ def test_set_start_location(): location1 = Location(1, exits=[exit1]) dungeon = Dungeon(locations=[location1]) - with pytest.raises(LocationNotFoundError): - dungeon.set_start_location(99) + assert dungeon.set_start_location(99) == None start_location = dungeon.set_start_location(1) assert start_location.id == 1