diff --git a/osrgame/osrgame/osrgame.py b/osrgame/osrgame/osrgame.py index e986f6d..f2bcafc 100644 --- a/osrgame/osrgame/osrgame.py +++ b/osrgame/osrgame/osrgame.py @@ -17,14 +17,12 @@ class OSRConsole(App): player_party = None adventure = None dungeon_master = None + openai_model = OpenAIModelVersion.GPT4TURBO CSS_PATH = "screen.tcss" BINDINGS = [ - ("c", "character", "Character"), - # ("e", "explore", "Explore"), - # ("ctrl+l", "load", "Load"), - ("escape", "app.pop_screen", "Pop screen"), + ("escape", "previous_screen", "Previous screen"), ("q", "quit", "Quit"), ] @@ -41,17 +39,14 @@ def compose(self) -> ComposeResult: def on_mount(self) -> None: self.title = "OSR Console" - self.sub_title = "Adventures in turn-based text" + self.sub_title = f"Adventures in turn-based text (model: {self.openai_model.value})" - def action_character(self) -> None: - """Show the character screen.""" - self.push_screen("screen_character") + ### Actions ### - def action_explore(self) -> None: - """Show the explore screen.""" - if self.adventure is None: - self.set_active_adventure() - self.push_screen("screen_explore") + def action_previous_screen(self) -> None: + """Return to the previous screen.""" + if len(self.screen_stack) > 1: + self.pop_screen() def action_quit(self) -> None: """Quit the application.""" @@ -80,7 +75,7 @@ def set_active_adventure(self, adventure: Adventure = None) -> None: dungeon = Dungeon.get_random_dungeon("Dungeon of the Raving Mage", "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=OpenAIModelVersion.GPT35TURBO) + num_locations=50, use_ai=True, openai_model=self.openai_model) dungeon.set_start_location(1) if dungeon.validate_location_connections(): @@ -97,7 +92,7 @@ def start_session(self) -> str: if self.adventure is None: self.set_active_adventure(adventure=None) - self.dungeon_master = DungeonMaster(self.adventure, openai_model=OpenAIModelVersion.GPT35TURBO) + self.dungeon_master = DungeonMaster(self.adventure, openai_model=self.openai_model) dm_start_session_response = self.dungeon_master.start_session() logger.debug(f"DM start session response: {dm_start_session_response}") diff --git a/osrgame/osrgame/screen_adventure_browser.py b/osrgame/osrgame/screen_adventure_browser.py index f673074..93c2214 100644 --- a/osrgame/osrgame/screen_adventure_browser.py +++ b/osrgame/osrgame/screen_adventure_browser.py @@ -6,7 +6,7 @@ from textual.app import ComposeResult from textual.containers import Container, VerticalScroll from textual.screen import Screen -from textual.widgets import DirectoryTree, Footer, Header, Static +from textual.widgets import Footer, Header, Static from osrlib.adventure import Adventure @@ -16,8 +16,7 @@ class AdventureBrowserScreen(Screen): """File browser for selecting an adventure to load.""" BINDINGS = [ - ("q", "quit", "Quit"), - ("l", "load_adventure", "Load adventure"), + ("ctrl+l", "load_adventure", "Load selected adventure"), ] adventure_file_path = None @@ -70,9 +69,9 @@ def action_load_adventure(self) -> None: try: loaded_adventure = Adventure.load_adventure(self.adventure_file_path) self.app.set_active_adventure(loaded_adventure) - self.app.start_session() self.app.pop_screen() self.app.push_screen("screen_explore") + self.app.start_session() except Exception: # Get the traceback and display it in the code view. code_view = self.query_one("#code", Static) diff --git a/osrgame/osrgame/screen_explore.py b/osrgame/osrgame/screen_explore.py index d26f277..449b7fe 100644 --- a/osrgame/osrgame/screen_explore.py +++ b/osrgame/osrgame/screen_explore.py @@ -1,8 +1,7 @@ from textual.app import ComposeResult from textual.screen import Screen -from textual.widgets import Header, Footer, Log, Placeholder +from textual.widgets import Header, Footer, Log -from osrlib.adventure import Adventure from osrlib.dungeon import Direction from osrlib.utils import wrap_text @@ -11,25 +10,23 @@ class ExploreScreen(Screen): BINDINGS = [ - ("b", "start_session", "Begin session"), ("n", "move_north", "North"), ("s", "move_south", "South"), ("e", "move_east", "East"), ("w", "move_west", "West"), - ("k", "clear_logs", "Clear logs"), - ("?", "summarize", "Summarize session"), + ("?", "summarize", "Describe location"), + ("c", "character", "Character screen"), + ("h", "heal_party", "Heal party"), ("ctrl+s", "save_game", "Save game"), - ("ctrl+h", "heal_party", "Heal party"), ] dungeon_master = None - save_path = None def compose(self) -> ComposeResult: yield Header(show_clock=True) yield PartyRosterTable(id="pc_party_table", classes="box") # upper-left - yield Log(id="dm_log", auto_scroll=True, classes="box") # right (row-span=2) - yield Log(id="player_log", auto_scroll=True, classes="box") # lower-left + yield Log(id="dm_log", auto_scroll=True, classes="box") # right (row-span=2) + yield Log(id="player_log", auto_scroll=True, classes="box") # lower-left yield Footer() def on_mount(self) -> None: @@ -38,41 +35,49 @@ def on_mount(self) -> None: self.query_one("#dm_log", Log).border_title = "Adventure Log" self.query_one(PartyRosterTable).border_title = "Adventuring Party" self.query_one(PartyRosterTable).update_table() + self.action_summarize() def perform_move_action(self, direction: Direction, log_message: str) -> None: """Move the party in the specified direction, execute battle (if any), and log the results.""" - self.query_one("#player_log").write_line("---") self.query_one("#player_log").write_line(log_message) dm_response = self.dungeon_master.move_party(direction) - self.query_one("#dm_log").write_line("---") - self.query_one("#dm_log").write_line("> " + str(self.dungeon_master.adventure.active_dungeon.current_location)) + self.query_one("#dm_log").write_line( + "> " + str(self.dungeon_master.adventure.active_dungeon.current_location) + ) self.query_one("#dm_log").write_line(wrap_text(dm_response)) - self.query_one("#dm_log").write_line(f"[model: {self.dungeon_master.openai_model}]") - self.query_one("#dm_log").write_line(" ") self.check_for_encounter() def check_for_encounter(self) -> None: """Check for an encounter and execute battle if there are monsters in the encounter.""" - if self.dungeon_master.adventure.active_dungeon.current_location.encounter and not self.dungeon_master.adventure.active_dungeon.current_location.encounter.is_ended: - encounter = self.dungeon_master.adventure.active_dungeon.current_location.encounter - - self.query_one("#dm_log").write_line("> Encounter!") - - # TODO: Check whether monsters were surprised, and if so, give the player a chance to flee. - self.query_one("#player_log").write_line("Fight!") + if ( + self.dungeon_master.adventure.active_dungeon.current_location.encounter + and not self.dungeon_master.adventure.active_dungeon.current_location.encounter.is_ended + ): + encounter = ( + self.dungeon_master.adventure.active_dungeon.current_location.encounter + ) + + if ( + encounter.monster_party is not None + and len(encounter.monster_party.members) > 0 + ): + self.query_one("#dm_log").write_line("> Encounter:") + self.query_one("#dm_log").write_line(f" {encounter.monster_party}".replace("\n", "\n ")) + + # TODO: Check whether monsters were surprised, and if so, give the player a chance to flee. + self.query_one("#player_log").write_line("> Fight!") encounter.start_encounter(self.dungeon_master.adventure.active_party) encounter_log = encounter.get_encounter_log() dm_response = self.dungeon_master.summarize_battle(encounter_log) self.query_one("#dm_log").write_line(wrap_text(dm_response)) - self.query_one("#dm_log").write_line(f"[model: {self.dungeon_master.openai_model}]") - self.query_one("#dm_log").write_line(" ") self.query_one("#pc_party_table").update_table() + self.query_one("#dm_log").write_line("---") def action_quit(self) -> None: """Quit the application.""" @@ -80,72 +85,60 @@ def action_quit(self) -> None: def action_move_north(self) -> None: """Move the party north.""" - self.perform_move_action(Direction.NORTH, "Moving north...") + self.perform_move_action(Direction.NORTH, "> Move north") def action_move_south(self) -> None: """Move the party south.""" - self.perform_move_action(Direction.SOUTH, "Moving south...") + self.perform_move_action(Direction.SOUTH, "> Move south") def action_move_east(self) -> None: """Move the party east.""" - self.perform_move_action(Direction.EAST, "Moving east...") + self.perform_move_action(Direction.EAST, "> Move east") def action_move_west(self) -> None: """Move the party west.""" - self.perform_move_action(Direction.WEST, "Moving west...") + self.perform_move_action(Direction.WEST, "> Move west") def action_move_up(self) -> None: """Move the party up.""" - self.perform_move_action(Direction.UP, "Climbing up the stairs...") + self.perform_move_action(Direction.UP, "> Ascend stairs") def action_move_down(self) -> None: """Move the party down.""" - self.perform_move_action(Direction.DOWN, "Descending the stairs...") + self.perform_move_action(Direction.DOWN, "> Descend stairs") - def action_clear_logs(self) -> None: + def clear_logs(self) -> None: """An action to clear the logs.""" self.query_one("#player_log").clear() self.query_one("#dm_log").clear() def action_summarize(self) -> None: """An action to summarize the session.""" - self.query_one("#player_log").write_line("Summarizing session...") - self.query_one("#player_log").write_line("===") - formatted_message = self.dungeon_master.format_user_message("Please provide a journal entry for the adventurers that summarizes the locations they've seen and encounters they've had in this game session. Include only what the player characters in the adventuring party would know. Include stats at the very end with number of locations visited, monsters killed, PCs killed, and total experience points earned.") + self.query_one("#player_log").write_line("> Describe location") + formatted_message = self.dungeon_master.format_user_message( + "Please describe this location again, including specifying the exit that we entered from and which exit or exits, if any, we haven't yet explored: " \ + + str(self.dungeon_master.adventure.active_dungeon.current_location.json) + ) dm_response = self.dungeon_master.player_message(formatted_message) + self.query_one("#dm_log").write_line( + "> " + str(self.dungeon_master.adventure.active_dungeon.current_location) + ) self.query_one("#dm_log").write_line(wrap_text(dm_response)) - self.query_one("#dm_log").write_line(f"[model: {self.dungeon_master.openai_model}]") - self.query_one("#dm_log").write_line("===") + self.query_one("#dm_log").write_line("---") - def action_save_game(self) -> None: - """An action to save the game.""" - self.query_one("#player_log").write_line("> Save adventure") - self.query_one("#player_log").write_line("---") - self.query_one("#dm_log").write_line("> Saving adventure...") - self.save_path = self.dungeon_master.adventure.save_adventure() - self.query_one("#dm_log").write_line(f"Adventure '{self.dungeon_master.adventure.name}' saved to {self.save_path}.") - self.query_one("#dm_log").write_line("===") + def action_character(self) -> None: + """Show the character screen.""" + self.app.push_screen("screen_character") def action_heal_party(self) -> None: """An action to heal the party.""" self.query_one("#player_log").write_line("> Heal party") - self.query_one("#player_log").write_line("---") - self.query_one("#dm_log").write_line("> Healing party...") self.dungeon_master.adventure.active_party.heal_party() - self.query_one("#dm_log").write_line("Party healed.") + self.query_one("#player_log").write_line(" Party healed.") self.query_one("#pc_party_table").update_table() - - def action_load_game(self) -> None: + def action_save_game(self) -> None: """An action to save the game.""" - self.query_one("#player_log").write_line("> Load adventure") - self.query_one("#player_log").write_line("---") - self.query_one("#dm_log").write_line("> Loading adventure...") - if self.save_path is None: - self.query_one("#dm_log").write_line("No save path found.") - else: - self.app.adventure = Adventure.load_adventure(self.save_path) - loaded_adventure = Adventure.load_adventure(self.save_path) - self.dungeon_master.adventure = loaded_adventure - self.query_one("#dm_log").write_line(f"Adventure '{loaded_adventure.name}' loaded from {self.save_path}.") - self.query_one("#pc_party_table").update_table() + self.query_one("#player_log").write_line("> Save adventure") + save_path = self.dungeon_master.adventure.save_adventure() + self.query_one("#player_log").write_line(f" Saved to: {save_path}") diff --git a/osrgame/osrgame/screen_welcome.py b/osrgame/osrgame/screen_welcome.py index 01fae88..cc40b5b 100644 --- a/osrgame/osrgame/screen_welcome.py +++ b/osrgame/osrgame/screen_welcome.py @@ -10,16 +10,11 @@ class WelcomeScreen(Screen): - BINDINGS = [ - ("escape", "app.pop_screen", "Pop screen"), - ("q", "quit", "Quit"), - ] def compose(self) -> ComposeResult: yield Header(show_clock=True, id="header") yield WelcomeScreenButtons(id="welcome-buttons") yield Footer() - def on_mount(self) -> None: pass @@ -47,8 +42,7 @@ def quit_button_pressed(self) -> None: def action_start_default_adventure(self) -> None: """Start the default adventure, which is an adventure with one randomly generated dungeon.""" self.app.set_active_adventure(adventure=None) - dm_response = self.app.start_session() - # TODO: set up a textual reactive property for the DM response and watch that in all the screens + dm_response = self.app.start_session() # TODO: dm_response should be a textual reactive property self.app.push_screen("screen_explore") def action_load_adventure(self) -> None: diff --git a/osrgame/osrgame/widgets.py b/osrgame/osrgame/widgets.py index b34b3df..d38dfb6 100644 --- a/osrgame/osrgame/widgets.py +++ b/osrgame/osrgame/widgets.py @@ -93,7 +93,7 @@ def compose(self) -> ComposeResult: def on_mount(self) -> None: """Perform actions when the widget is mounted.""" table = self.query_one(DataTable) - table.add_columns("Name", "Class", "Level", "HP", "AC") + table.add_columns("Name", "Class", "Level", "HP", "AC", "XP") def update_table(self): party = self.app.adventure.active_party @@ -104,8 +104,9 @@ def update_table(self): pc.name, pc.character_class.class_type.value, Text(str(pc.level), justify="center"), - Text(f"{str(pc.hit_points)}/{str(pc.character_class.max_hp)}", justify="center"), + Text(f"{str(pc.hit_points)}/{str(pc.max_hit_points)}", justify="center"), Text(str(pc.armor_class), justify="center"), + Text(str(pc.xp) + "/" + str(pc.xp_needed_for_next_level), justify="center"), ] table.add_row(*row_data, key=pc.name) diff --git a/osrgame/poetry.lock b/osrgame/poetry.lock index ec3e5eb..0123bce 100644 --- a/osrgame/poetry.lock +++ b/osrgame/poetry.lock @@ -1028,7 +1028,7 @@ datalib = ["numpy (>=1)", "pandas (>=1.2.3)", "pandas-stubs (>=1.1.0.11)"] [[package]] name = "osrlib" -version = "0.1.18" +version = "0.1.27" description = "Turn-based dungeon-crawler game engine for OSR-style RPGs." optional = false python-versions = "^3.11" diff --git a/osrlib/osrlib/dungeon.py b/osrlib/osrlib/dungeon.py index 9369cb4..538f2d7 100644 --- a/osrlib/osrlib/dungeon.py +++ b/osrlib/osrlib/dungeon.py @@ -128,7 +128,7 @@ def __init__( def __str__(self): exits_str = ", ".join(str(exit) for exit in self.exits) - return f"Loc ID: {self.id} Size: {str(self.dimensions['width'])}'W x {str(self.dimensions['length'])}'L Exits: [{exits_str}] Keywords: {self.keywords}" + return f"LOC ID: {self.id} Size: {str(self.dimensions['width'])}'W x {str(self.dimensions['length'])}'L Exits: [{exits_str}] Keywords: {self.keywords}" @property def json(self): diff --git a/osrlib/osrlib/encounter.py b/osrlib/osrlib/encounter.py index e0bc5ad..3ff894e 100644 --- a/osrlib/osrlib/encounter.py +++ b/osrlib/osrlib/encounter.py @@ -53,7 +53,12 @@ def __init__( def __str__(self): """Return a string representation of the encounter.""" - return f"{self.name}: {self.description} ({len(self.monster_party.members)} {self.monster_party.members[0].name if self.monster_party.members else ''})" + if self.monster_party is None: + return f"{self.name}: {self.description}" + elif len(self.monster_party.members) == 0: + return f"{self.name}: {self.description} (no monsters)" + else: + return f"{self.name}: {self.description} ({len(self.monster_party.members)} {self.monster_party.members[0].name if self.monster_party.members else ''})" def log_mesg(self, message: str): """Log an encounter log message.""" @@ -203,16 +208,18 @@ def execute_combat_round(self, round_num: int): def end_encounter(self): logger.debug(f"Ending encounter '{self.name}'...") - self.log_mesg(pylog.last_message) - if self.pc_party.is_alive and not self.monster_party.is_alive: - logger.debug( - f"{self.pc_party.name} won the battle! Awarding {self.monster_party.xp_value} experience points to the party..." - ) - self.log_mesg(pylog.last_message) - self.pc_party.grant_xp(self.monster_party.xp_value) - elif not self.pc_party.is_alive and self.monster_party.is_alive: - logger.debug(f"{self.pc_party.name} lost the battle.") + + if self.pc_party: self.log_mesg(pylog.last_message) + if self.pc_party.is_alive and not self.monster_party.is_alive: + logger.debug( + f"{self.pc_party.name} won the battle! Awarding {self.monster_party.xp_value} experience points to the party..." + ) + self.log_mesg(pylog.last_message) + self.pc_party.grant_xp(self.monster_party.xp_value) + elif not self.pc_party.is_alive and self.monster_party.is_alive: + logger.debug(f"{self.pc_party.name} lost the battle.") + self.log_mesg(pylog.last_message) self.is_started = False self.is_ended = True @@ -267,16 +274,17 @@ def to_dict(self): "description": self.description, "monsters": self.monster_party.to_dict(), "treasure": self.treasure, + "is_ended": self.is_ended, } return encounter_dict @classmethod - def from_dict(cls, encounter_dict: dict): + def from_dict(cls, encounter_dict: dict) -> 'Encounter': """Deserialize a dictionary into an Encounter instance. This class method creates a new instance of Encounter using the data provided in a dictionary. The dictionary should contain keys corresponding to the attributes of an Encounter, including a serialized - MonsterParty instance. + MonsterParty instance. If 'is_ended' is True, the end_encounter() method will be called. Args: encounter_dict (dict): A dictionary containing the encounter's attributes. @@ -289,15 +297,24 @@ def from_dict(cls, encounter_dict: dict): encounter = Encounter.from_dict(encounter_dict) # The 'encounter' is now a rehydrated instance of the Encounter class """ - name = encounter_dict["name"] - description = encounter_dict["description"] - monster_party = MonsterParty.from_dict(encounter_dict["monsters"]) - treasure = encounter_dict["treasure"] - - encounter = cls( - name, - description, - monster_party, - treasure, - ) - return encounter + try: + name = encounter_dict["name"] + description = encounter_dict["description"] + monster_party = MonsterParty.from_dict(encounter_dict["monsters"]) + treasure = encounter_dict["treasure"] + is_ended = encounter_dict.get("is_ended", False) + + encounter = cls( + name, + description, + monster_party, + treasure, + ) + + if is_ended and isinstance(is_ended, bool): + encounter.end_encounter() + + return encounter + + except KeyError as e: + raise ValueError(f"Missing key in encounter_dict: {e}") diff --git a/osrlib/osrlib/monster.py b/osrlib/osrlib/monster.py index a3355ee..419e4e5 100644 --- a/osrlib/osrlib/monster.py +++ b/osrlib/osrlib/monster.py @@ -137,7 +137,7 @@ def __init__( self.attacks_per_round = attacks_per_round self.damage_per_attack = damage_per_attack self.num_appearing_dice_string = num_appearing - self.num_appearing = roll_dice(self.num_appearing_dice_string).total_with_modifier + self.num_appearing = max(roll_dice(self.num_appearing_dice_string).total_with_modifier, 1) self.save_as_class = save_as_class self.save_as_level = save_as_level self.morale = morale @@ -209,7 +209,7 @@ def __init__(self, monster_stats: MonsterStatsBlock): self.armor_class = monster_stats.armor_class self.hp_roll = roll_dice(monster_stats.hit_dice) - self.hit_points = self.hp_roll.total_with_modifier + self.hit_points = max(self.hp_roll.total_with_modifier, 1) self.max_hit_points = self.hit_points self.movement = monster_stats.movement @@ -225,6 +225,10 @@ def __init__(self, monster_stats: MonsterStatsBlock): self.hp_roll, monster_stats.num_special_abilities ) + def __str__(self): + """Get a string representation of the monster.""" + return f"{self.name} (HP: {self.hit_points}/{self.max_hit_points} AC: {self.armor_class} XP value: {self.xp_value})" + def _calculate_xp(self, hp_roll: DiceRoll, num_special_abilities: int = 0): """Get the total XP value of the monster. The XP value is based on the monster's hit dice and number of special abilities. @@ -376,6 +380,10 @@ def __init__(self, monster_stats_block: MonsterStatsBlock): self.treasure = self._get_treasure(monster_stats_block.treasure_type) self.is_surprised = False + def __str__(self): + """Get a string representation of the monster party.""" + return "\n".join([str(member) for member in self.members]) + def _get_treasure(self, treasure_type: TreasureType): """Get the treasure for the monster party based on the treasure type. diff --git a/osrlib/osrlib/player_character.py b/osrlib/osrlib/player_character.py index c0f771c..cde71a6 100644 --- a/osrlib/osrlib/player_character.py +++ b/osrlib/osrlib/player_character.py @@ -68,9 +68,9 @@ def __str__(self): ) return ( f"{self.name} ({self.character_class} {self.level}) " - f"HP: {self.hit_points}/{self.character_class.max_hp} " + f"HP: {self.hit_points}/{self.max_hit_points} " f"AC: {self.armor_class} " - f"XP: {self.character_class.xp}/{self.character_class.xp_needed_for_next_level}" + f"XP: {self.xp}/{self.xp_needed_for_next_level}" ) @property @@ -96,6 +96,11 @@ def level(self): def hit_points(self): return self.character_class.hp + @property + def max_hit_points(self) -> int: + """Get the maximum hit points of the character.""" + return self.character_class.max_hp + @property def armor_class(self): """Get the armor class of the character.""" @@ -108,6 +113,16 @@ def armor_class(self): ) return armor_class + @property + def xp(self) -> int: + """Get the character's current XP total.""" + return self.character_class.xp + + @property + def xp_needed_for_next_level(self) -> int: + """Get the amount of XP needed for the character to reach the next level.""" + return self.character_class.xp_needed_for_next_level + def get_ability_roll(self): """Rolls a 4d6 and returns the sum of the three highest rolls.""" roll = roll_dice("4d6", drop_lowest=True) diff --git a/osrlib/pyproject.toml b/osrlib/pyproject.toml index ad6299b..8f467b7 100644 --- a/osrlib/pyproject.toml +++ b/osrlib/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "osrlib" -version = "0.1.18" +version = "0.1.27" description = "Turn-based dungeon-crawler game engine for OSR-style RPGs." authors = ["Marsh Macy "] license = "MIT"