diff --git a/osrlib/osrlib/__init__.py b/osrlib/osrlib/__init__.py index f4b2ec7..a89217d 100644 --- a/osrlib/osrlib/__init__.py +++ b/osrlib/osrlib/__init__.py @@ -47,6 +47,9 @@ LocationNotFoundError, Direction, ) +from .enums import ( + CharacterClassType, +) from .game_manager import ( GameManager, StorageType, @@ -87,6 +90,7 @@ CharacterAlreadyInPartyError, get_default_party, ) -from .player_character import PlayerCharacter +from .player_character import PlayerCharacter, Alignment from .quest import Quest +from .saving_throws import saving_throws from .utils import format_modifiers diff --git a/osrlib/osrlib/ability.py b/osrlib/osrlib/ability.py index e5fd52d..7acc370 100644 --- a/osrlib/osrlib/ability.py +++ b/osrlib/osrlib/ability.py @@ -3,7 +3,7 @@ from abc import ABC, abstractmethod from enum import Enum -from osrlib.character_classes import CharacterClassType +from osrlib.enums import CharacterClassType from osrlib.combat import ModifierType diff --git a/osrlib/osrlib/character_classes.py b/osrlib/osrlib/character_classes.py index 3bf7833..e93e738 100644 --- a/osrlib/osrlib/character_classes.py +++ b/osrlib/osrlib/character_classes.py @@ -1,22 +1,9 @@ """Defines character classes for player characters.""" -from enum import Enum from typing import List, Tuple, Union -from osrlib.combat import AttackType from osrlib.dice_roller import DiceRoll, roll_dice - - -class CharacterClassType(Enum): - """Enum representing the types of character classes.""" - - CLERIC = "Cleric" - DWARF = "Dwarf" - ELF = "Elf" - FIGHTER = "Fighter" - HALFLING = "Halfling" - MAGIC_USER = "Magic User" - THIEF = "Thief" - COMMONER = "Commoner" +from osrlib.enums import CharacterClassType +from osrlib.saving_throws import saving_throws class ClassLevel: @@ -66,7 +53,7 @@ class CharacterClass: def __init__(self, character_class_type: CharacterClassType, level: int = 1, constitution_modifier: int = 0): """Initialize a CharacterClass instance.""" self.class_type = character_class_type - self.saving_throws = saving_throws[self.class_type] + self.class_saving_throws = saving_throws[self.class_type] self.levels = class_levels[self.class_type] self.current_level = self.levels[level] self.hit_die = self.levels[1].hit_dice # hit die is always first-level (1dn) @@ -83,6 +70,16 @@ def __str__(self) -> str: """Return a string representation of the CharacterClass instance.""" return self.class_type.value + @property + def saving_throws(self) -> List[int]: + """Return the saving throws for the character at their current class level.""" + for level_range in self.class_saving_throws: + if self.current_level in level_range: + return self.class_saving_throws[level_range] + + raise ValueError(f"No saving throws available for {self.class_type.value} at level {self.current_level}") + + def roll_hp(self, hp_modifier: int = 0) -> DiceRoll: """Roll hit points for the character. @@ -286,65 +283,6 @@ def level_up(self, hp_modifier: int = 0) -> ClassLevel: } -saving_throws = { - CharacterClassType.CLERIC: { - AttackType.DEATH_RAY_POISON: 11, - AttackType.MAGIC_WANDS: 12, - AttackType.PARALYSIS_TURN_TO_STONE: 14, - AttackType.DRAGON_BREATH: 16, - AttackType.RODS_STAVES_SPELLS: 15, - }, - CharacterClassType.DWARF: { - AttackType.DEATH_RAY_POISON: 10, - AttackType.MAGIC_WANDS: 11, - AttackType.PARALYSIS_TURN_TO_STONE: 12, - AttackType.DRAGON_BREATH: 13, - AttackType.RODS_STAVES_SPELLS: 14, - }, - CharacterClassType.ELF: { - AttackType.DEATH_RAY_POISON: 12, - AttackType.MAGIC_WANDS: 13, - AttackType.PARALYSIS_TURN_TO_STONE: 13, - AttackType.DRAGON_BREATH: 15, - AttackType.RODS_STAVES_SPELLS: 15, - }, - CharacterClassType.FIGHTER: { - AttackType.DEATH_RAY_POISON: 12, - AttackType.MAGIC_WANDS: 13, - AttackType.PARALYSIS_TURN_TO_STONE: 14, - AttackType.DRAGON_BREATH: 15, - AttackType.RODS_STAVES_SPELLS: 16, - }, - CharacterClassType.HALFLING: { - AttackType.DEATH_RAY_POISON: 10, - AttackType.MAGIC_WANDS: 11, - AttackType.PARALYSIS_TURN_TO_STONE: 12, - AttackType.DRAGON_BREATH: 13, - AttackType.RODS_STAVES_SPELLS: 14, - }, - CharacterClassType.MAGIC_USER: { - AttackType.DEATH_RAY_POISON: 13, - AttackType.MAGIC_WANDS: 14, - AttackType.PARALYSIS_TURN_TO_STONE: 13, - AttackType.DRAGON_BREATH: 16, - AttackType.RODS_STAVES_SPELLS: 15, - }, - CharacterClassType.THIEF: { - AttackType.DEATH_RAY_POISON: 13, - AttackType.MAGIC_WANDS: 14, - AttackType.PARALYSIS_TURN_TO_STONE: 13, - AttackType.DRAGON_BREATH: 16, - AttackType.RODS_STAVES_SPELLS: 15, - }, - CharacterClassType.COMMONER: { - AttackType.DEATH_RAY_POISON: 20, - AttackType.MAGIC_WANDS: 20, - AttackType.PARALYSIS_TURN_TO_STONE: 20, - AttackType.DRAGON_BREATH: 20, - AttackType.RODS_STAVES_SPELLS: 20, - }, -} - all_character_classes = [ CharacterClass(CharacterClassType.CLERIC), diff --git a/osrlib/osrlib/dungeon.py b/osrlib/osrlib/dungeon.py index f3375b1..3c6080c 100644 --- a/osrlib/osrlib/dungeon.py +++ b/osrlib/osrlib/dungeon.py @@ -1,4 +1,3 @@ -from random import randint, sample from typing import List from enum import Enum import random @@ -126,7 +125,9 @@ def __init__( self.is_visited = is_visited def __str__(self): - return f"Location ID: {self.id} Dimensions: {self.dimensions} Exits: {self.exits} Keywords: {self.keywords}" + exits_str = ", ".join(str(exit) for exit in self.exits) + return f"Location ID: {self.id} Dimensions: {self.dimensions} Exits: [{exits_str}] Keywords: {self.keywords}" + @property def json(self): @@ -329,7 +330,8 @@ def move(self, direction: Direction) -> Location: exit = [exit for exit in self.current_location.exits if exit.direction == direction][0] except IndexError: gm.logger.debug( - f"No exit to the {direction.name} from {self.current_location}. The only exits are {self.current_location.exits}." + f"No exit to the {direction.name} from {self.current_location}. The only exits are: " + + ", ".join(str(exit) for exit in self.current_location.exits) + "." ) return None diff --git a/osrlib/osrlib/enums.py b/osrlib/osrlib/enums.py new file mode 100644 index 0000000..d9bd591 --- /dev/null +++ b/osrlib/osrlib/enums.py @@ -0,0 +1,13 @@ +from enum import Enum + +class CharacterClassType(Enum): + """Enum representing the types of character classes.""" + + CLERIC = "Cleric" + DWARF = "Dwarf" + ELF = "Elf" + FIGHTER = "Fighter" + HALFLING = "Halfling" + MAGIC_USER = "Magic User" + THIEF = "Thief" + COMMONER = "Commoner" diff --git a/osrlib/osrlib/item.py b/osrlib/osrlib/item.py index 10a1b84..4c5adfb 100644 --- a/osrlib/osrlib/item.py +++ b/osrlib/osrlib/item.py @@ -2,7 +2,7 @@ from enum import Enum from typing import Optional, Set -from osrlib import CharacterClassType +from osrlib.enums import CharacterClassType class ItemType(Enum): diff --git a/osrlib/osrlib/item_factories.py b/osrlib/osrlib/item_factories.py index 747cc98..a33295c 100644 --- a/osrlib/osrlib/item_factories.py +++ b/osrlib/osrlib/item_factories.py @@ -1,4 +1,5 @@ -from osrlib import CharacterClassType, Armor, Item, ItemType, Weapon +from osrlib.item import Armor, Item, ItemType, Weapon +from osrlib.enums import CharacterClassType _armor_combat_classes = { CharacterClassType.FIGHTER, @@ -26,7 +27,22 @@ "Leather Armor": {"ac": -2, "gp_value": 20, "usable_by": _armor_combat_classes | {CharacterClassType.THIEF}}, "Plate Mail": {"ac": -6, "gp_value": 60, "usable_by": _armor_combat_classes}, "Shield": {"ac": -1, "gp_value": 10, "usable_by": _armor_combat_classes}, - "Robe": {"ac": 0, "gp_value": 1, "usable_by": {CharacterClassType.MAGIC_USER}}, + "Robes": {"ac": 0, "gp_value": 1, "usable_by": {CharacterClassType.MAGIC_USER}}, +} + +magic_armor_data = { + "Chain Mail +1": {"ac": -5, "gp_value": 400, "usable_by": _armor_combat_classes}, + "Chain Mail +2": {"ac": -6, "gp_value": 800, "usable_by": _armor_combat_classes}, + "Chain Mail +3": {"ac": -7, "gp_value": 1600, "usable_by": _armor_combat_classes}, + "Leather Armor +1": {"ac": -3, "gp_value": 200, "usable_by": _armor_combat_classes | {CharacterClassType.THIEF}}, + "Leather Armor +2": {"ac": -4, "gp_value": 400, "usable_by": _armor_combat_classes | {CharacterClassType.THIEF}}, + "Leather Armor +3": {"ac": -5, "gp_value": 800, "usable_by": _armor_combat_classes | {CharacterClassType.THIEF}}, + "Plate Mail +1": {"ac": -7, "gp_value": 600, "usable_by": _armor_combat_classes}, + "Plate Mail +2": {"ac": -8, "gp_value": 1200, "usable_by": _armor_combat_classes}, + "Plate Mail +3": {"ac": -9, "gp_value": 2400, "usable_by": _armor_combat_classes}, + "Shield +1": {"ac": -2, "gp_value": 100, "usable_by": _armor_combat_classes}, + "Shield +2": {"ac": -3, "gp_value": 200, "usable_by": _armor_combat_classes}, + "Shield +3": {"ac": -4, "gp_value": 400, "usable_by": _armor_combat_classes}, } equipment_data = { @@ -73,6 +89,51 @@ "War Hammer": {"damage": "1d6", "gp_value": 5, "usable_by": _weapon_combat_classes | {CharacterClassType.CLERIC}} } +magic_weapon_data = { + "Battle Axe +1": {"damage": "1d8+1", "gp_value": 70, "usable_by": _weapon_combat_classes}, + "Battle Axe +2": {"damage": "1d8+2", "gp_value": 140, "usable_by": _weapon_combat_classes}, + "Battle Axe +3": {"damage": "1d8+3", "gp_value": 280, "usable_by": _weapon_combat_classes}, + "Club +1": {"damage": "1d4+1", "gp_value": 30, "usable_by": _weapon_combat_classes | {CharacterClassType.CLERIC}}, + "Club +2": {"damage": "1d4+2", "gp_value": 60, "usable_by": _weapon_combat_classes | {CharacterClassType.CLERIC}}, + "Club +3": {"damage": "1d4+3", "gp_value": 120, "usable_by": _weapon_combat_classes | {CharacterClassType.CLERIC}}, + "Crossbow +1": {"damage": "1d4+1", "gp_value": 130, "usable_by": _weapon_combat_classes, "range": 160}, + "Crossbow +2": {"damage": "1d4+2", "gp_value": 260, "usable_by": _weapon_combat_classes, "range": 160}, + "Crossbow +3": {"damage": "1d4+3", "gp_value": 520, "usable_by": _weapon_combat_classes, "range": 160}, + "Dagger +1": {"damage": "1d4+1", "gp_value": 30, "usable_by": _weapon_combat_classes | {CharacterClassType.MAGIC_USER}, "range": 20}, + "Dagger +2": {"damage": "1d4+2", "gp_value": 60, "usable_by": _weapon_combat_classes | {CharacterClassType.MAGIC_USER}, "range": 20}, + "Dagger +3": {"damage": "1d4+3", "gp_value": 120, "usable_by": _weapon_combat_classes | {CharacterClassType.MAGIC_USER}, "range": 20}, + "Long Bow +1": {"damage": "1d6+1", "gp_value": 130, "usable_by": {CharacterClassType.FIGHTER, CharacterClassType.ELF}, "range": 140}, + "Long Bow +2": {"damage": "1d6+2", "gp_value": 260, "usable_by": {CharacterClassType.FIGHTER, CharacterClassType.ELF}, "range": 140}, + "Long Bow +3": {"damage": "1d6+3", "gp_value": 520, "usable_by": {CharacterClassType.FIGHTER, CharacterClassType.ELF}, "range": 140}, + "Mace +1": {"damage": "1d6+1", "gp_value": 70, "usable_by": _weapon_combat_classes | {CharacterClassType.CLERIC}}, + "Mace +2": {"damage": "1d6+2", "gp_value": 140, "usable_by": _weapon_combat_classes | {CharacterClassType.CLERIC}}, + "Mace +3": {"damage": "1d6+3", "gp_value": 280, "usable_by": _weapon_combat_classes | {CharacterClassType.CLERIC}}, + "Pole Arm +1": {"damage": "1d10+1", "gp_value": 70, "usable_by": {CharacterClassType.FIGHTER, CharacterClassType.ELF, CharacterClassType.DWARF}}, + "Pole Arm +2": {"damage": "1d10+2", "gp_value": 140, "usable_by": {CharacterClassType.FIGHTER, CharacterClassType.ELF, CharacterClassType.DWARF}}, + "Pole Arm +3": {"damage": "1d10+3", "gp_value": 280, "usable_by": {CharacterClassType.FIGHTER, CharacterClassType.ELF, CharacterClassType.DWARF}}, + "Short Bow +1": {"damage": "1d6+1", "gp_value": 80, "usable_by": _weapon_combat_classes, "range": 100}, + "Short Bow +2": {"damage": "1d6+2", "gp_value": 160, "usable_by": _weapon_combat_classes, "range": 100}, + "Short Bow +3": {"damage": "1d6+3", "gp_value": 320, "usable_by": _weapon_combat_classes, "range": 100}, + "Silver Dagger +1": {"damage": "1d4+1", "gp_value": 130, "usable_by": _weapon_combat_classes | {CharacterClassType.MAGIC_USER}, "range": 20}, + "Silver Dagger +2": {"damage": "1d4+2", "gp_value": 260, "usable_by": _weapon_combat_classes | {CharacterClassType.MAGIC_USER}, "range": 20}, + "Silver Dagger +3": {"damage": "1d4+3", "gp_value": 520, "usable_by": _weapon_combat_classes | {CharacterClassType.MAGIC_USER}, "range": 20}, + "Sling +1": {"damage": "1d4+1", "gp_value": 130, "usable_by": _weapon_combat_classes | {CharacterClassType.CLERIC}, "range": 80}, + "Sling +2": {"damage": "1d4+2", "gp_value": 260, "usable_by": _weapon_combat_classes | {CharacterClassType.CLERIC}, "range": 80}, + "Sling +3": {"damage": "1d4+3", "gp_value": 520, "usable_by": _weapon_combat_classes | {CharacterClassType.CLERIC}, "range": 80}, + "Spear +1": {"damage": "1d6+1", "gp_value": 30, "usable_by": _weapon_combat_classes | {CharacterClassType.CLERIC}, "range": 40}, + "Spear +2": {"damage": "1d6+2", "gp_value": 60, "usable_by": _weapon_combat_classes | {CharacterClassType.CLERIC}, "range": 40}, + "Spear +3": {"damage": "1d6+3", "gp_value": 120, "usable_by": _weapon_combat_classes | {CharacterClassType.CLERIC}, "range": 40}, + "Sword +1": {"damage": "1d8+1", "gp_value": 90, "usable_by": _weapon_combat_classes}, + "Sword +2": {"damage": "1d8+2", "gp_value": 180, "usable_by": _weapon_combat_classes}, + "Sword +3": {"damage": "1d8+3", "gp_value": 360, "usable_by": _weapon_combat_classes}, + "Two-handed Sword +1": {"damage": "1d10+1", "gp_value": 100, "usable_by": {CharacterClassType.FIGHTER, CharacterClassType.ELF}}, + "Two-handed Sword +2": {"damage": "1d10+2", "gp_value": 200, "usable_by": {CharacterClassType.FIGHTER, CharacterClassType.ELF}}, + "Two-handed Sword +3": {"damage": "1d10+3", "gp_value": 400, "usable_by": {CharacterClassType.FIGHTER, CharacterClassType.ELF}}, + "War Hammer +1": {"damage": "1d6+1", "gp_value": 70, "usable_by": _weapon_combat_classes | {CharacterClassType.CLERIC}}, + "War Hammer +2": {"damage": "1d6+2", "gp_value": 140, "usable_by": _weapon_combat_classes | {CharacterClassType.CLERIC}}, + "War Hammer +3": {"damage": "1d6+3", "gp_value": 280, "usable_by": _weapon_combat_classes | {CharacterClassType.CLERIC}}, +} + class ItemDataNotFoundError(Exception): """Raised when item data is not found.""" @@ -228,7 +289,7 @@ def equip_halfling(character: "PlayerCharacter"): def equip_magic_user(character: "PlayerCharacter"): """Equip a Magic User character with starting gear.""" dagger = WeaponFactory.create_weapon("Dagger") - robe = ArmorFactory.create_armor("Robe") + robe = ArmorFactory.create_armor("Robes") backpack = EquipmentFactory.create_item("Backpack") spellbook = EquipmentFactory.create_item("Spell Book") diff --git a/osrlib/osrlib/monster.py b/osrlib/osrlib/monster.py index ecdadb0..6f9fab7 100644 --- a/osrlib/osrlib/monster.py +++ b/osrlib/osrlib/monster.py @@ -1,27 +1,145 @@ -from osrlib.dice_roller import roll_dice +from osrlib.dice_roller import roll_dice, DiceRoll +from osrlib.enums import CharacterClassType +from osrlib.player_character import Alignment +from osrlib.treasure import TreasureType -class Monster: - """A Monster is a creature the party can encounter in a dungeon and defeat to obtain experience points and optionally treasure and quest pieces. +monster_xp = { + "Under 1": {"base": 5, "bonus": 1}, # Not handling the "under 1" hit dice case + "1": {"base": 10, "bonus": 3}, + "1+": {"base": 15, "bonus": 4}, + "2": {"base": 20, "bonus": 5}, + "2+": {"base": 25, "bonus": 10}, + "3": {"base": 35, "bonus": 15}, + "3+": {"base": 50, "bonus": 25}, + "4": {"base": 75, "bonus": 50}, + "4+": {"base": 125, "bonus": 75}, + "5": {"base": 175, "bonus": 125}, + "5+": {"base": 225, "bonus": 175}, + "6": {"base": 275, "bonus": 225}, + "6+": {"base": 350, "bonus": 300}, + "7": {"base": 450, "bonus": 400}, + "8": {"base": 650, "bonus": 550}, + "9 to 10+": {"base": 900, "bonus": 700}, + "11 to 12+": {"base": 1100, "bonus": 800}, + "13 to 16+": {"base": 1350, "bonus": 950}, + "17 to 20+": {"base": 2000, "bonus": 1150}, + "21+": {"base": 2500, "bonus": 2000}, +} + + +class MonsterStatBlock: + """Specifies the statistics of the monsters in a monster party. + + Pass a MonsterStatBlock instance to the MonsterParty constructor to create a new + MonsterParty instance appropriate for populating an Encounter's monster_party attribute. Attributes: name (str): The name of the monster. - monster_type (MonsterType): The type of monster (DEMON, DRAGON, HUMANOID, MAGICAL, UNDEAD, etc.) description (str): The monster's description. - hit_dice (str): The hit dice of the monster in NdN format, like "1d8" or "2d6". - hit_points (int): The number of hit points the monster has. - weapon (Weapon): The weapon that the monster uses for attacks. - armor_class (int): The armor class of the monster. - treasure (list): A list of the treasure that the monster is carrying. The treasure can be any item like weapons, armor, quest pieces, or gold pieces (or gems or other valuables). + armor_class (int): The monster's armor class (AC). Lower is better. + hit_dice (str): The monster's hit dice in "nd8" or "nd8+n" format, for example '1d8', '1d8+2', '3d8'). Monster hit die are always a d8. Default is '1d8'. + movement (int): The monster's movement rate in feet per round. Default is 120. + num_special_abilities (int): The special ability count of the monster; this value corresponds to the number of asterisks after the monster's hit dice in the Basic and Expert rule books. Default is 0. + attacks_per_round (int): The number of attacks the monster can make per round. Default is 1. + damage_per_attack (str): The damage the monster does per attack in "ndn" or "ndn+n" format, for example '1d4', '1d4+2', '3d4'). Default is '1d4'. + num_appearing (str): The number of monsters that appear in the monster party in "ndn" or "ndn+n" format, for example '1d6', '1d6+2', '3d6'). Default is '1d6'. + save_as_class (CharacterClassType): The character class type the monster saves as. Default is CharacterClassType.FIGHTER. + save_as_level (int): The level of the character class the monster saves as. Default is 1. """ - def __init__(self, name, monster_type, description, hit_dice, weapon, armor_class, treasure): + + def __init__( + self, + name: str, + description: str = "", + armor_class: int = 10, + hit_dice: str = "1d8", + movement: int = 120, + num_special_abilities=0, # corresponds to the number of asterisks on the monster's hit dice + attacks_per_round=1, + damage_per_attack="1d4", + num_appearing="1d6", + save_as_class=CharacterClassType.FIGHTER, + save_as_level=1, + morale: int = 12, # roll 2d6, if result is higher, monster flees + treasure_type=TreasureType.NONE, + alignment=Alignment.NEUTRAL, + ): + """Initialize a new MonsterStatBlock instance.""" self.name = name - self.monster_type = monster_type self.description = description - self.hit_dice = hit_dice - self.weapon = weapon self.armor_class = armor_class - self.treasure = treasure - self.hit_points = roll_dice(hit_dice).total_with_modifier + self.hit_dice = hit_dice + self.movement = movement + self.num_special_abilities = num_special_abilities + self.attacks_per_round = attacks_per_round + self.damage_per_attack = damage_per_attack + self.num_appearing = num_appearing + self.save_as_class = save_as_class + self.save_as_level = save_as_level + self.morale = morale + self.treasure_type = treasure_type + self.alignment = alignment + + +class Monster: + """A Monster is a creature the party can encounter in a dungeon and defeat to obtain experience points and optionally treasure and quest pieces.""" + + def __init__(self, monster_stats: MonsterStatBlock): + """Initialize a new Monster instance.""" + self.name = monster_stats.name + self.description = monster_stats.description + self.armor_class = monster_stats.armor_class + + hp_roll = roll_dice(monster_stats.hit_dice) + self.hit_points = hp_roll.total_with_modifier + self.max_hit_points = self.hit_points + + self.movement = monster_stats.movement + self.attacks_per_round = monster_stats.attacks_per_round + self.damage_per_attack = monster_stats.damage_per_attack + self.save_as_class = monster_stats.save_as_class # TODO: Populate a saving throw table for the monster based on the save_as_class and save_as_level + self.save_as_level = monster_stats.save_as_level # TODO: Populate a saving throw table for the monster based on the save_as_class and save_as_level + self.morale = monster_stats.morale + self.alignment = monster_stats.alignment + + self.xp_value = self._calculate_xp(hp_roll, monster_stats.num_special_abilities) + + 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. + + Args: + hp_roll (DiceRoll): The roll of the monster's hit dice. + + Returns: + int: The total XP value of the monster. + """ + base_xp = 0 + plus = "" + if hp_roll.count <= 8: + if hp_roll.modifier > 0: + plus = "+" + base_xp = monster_xp[f"{hp_roll.count}{plus}"]["base"] + bonus = monster_xp[f"{hp_roll.count}{plus}"]["bonus"] + elif hp_roll.count >= 9 and hp_roll.count <= 10: + base_xp = monster_xp["9 to 10+"]["base"] + bonus = monster_xp["9 to 10+"]["bonus"] + elif hp_roll.count >= 11 and hp_roll.count <= 12: + base_xp = monster_xp["11 to 12+"]["base"] + bonus = monster_xp["11 to 12+"]["bonus"] + elif hp_roll.count >= 13 and hp_roll.count <= 16: + base_xp = monster_xp["13 to 16+"]["base"] + bonus = monster_xp["13 to 16+"]["bonus"] + elif hp_roll.count >= 17 and hp_roll.count <= 20: + base_xp = monster_xp["17 to 20+"]["base"] + bonus = monster_xp["17 to 20+"]["bonus"] + elif hp_roll.count >= 21: + base_xp = monster_xp["21+"]["base"] + bonus = monster_xp["21+"]["bonus"] + + # get the total XP value + total_xp = base_xp + bonus * num_special_abilities + + return total_xp @property def is_alive(self): @@ -32,30 +150,122 @@ def is_alive(self): """ return self.hit_points > 0 - def attack(self): - """Attack the party with the monster's weapon. - - Returns: - int: The amount of damage done to the party. - """ - # attack the party - damage = self.weapon.attack() - return damage + def apply_damage(self, hit_points_damage: int): + """Apply damage to the monster by reducing the monster's hit points by the given amount, down to a minimum of 0. - def damage(self, hit_points_damage: int): - """Apply damage from an attack. + This method has no affect if the monster is already dead. Args: damage (int): The amount of damage done to the monster. """ - self.hit_points -= hit_points_damage + if self.is_alive: + new_hp = self.hit_points - hit_points_damage + self.hit_points = max(new_hp, 0) def heal(self, hit_points_healed: int): - """Heal the monster by restoring the given amount of hit points. + """Heal the monster by restoring the given amount of hit points up to the monster's maximum hit points. + + This method has no affect if the monster is dead. Args: hit_points_healed (int): The amount of hit points to restore. """ - self.hit_points += hit_points_healed + if self.is_alive: + new_hp = self.hit_points + hit_points_healed + self.hit_points = min(new_hp, self.max_hit_points) + + +class MonsterParty: + """A group of monsters the party can encounter in a dungeon location. + + Attributes: + monsters (list): A list of the monsters in the monster party. + is_alive (bool): True if at least one monster in the monster party is alive, otherwise False. + """ + + def __init__(self, monster_stat_block: MonsterStatBlock): + """Initialize a new MonsterParty instance. + + The number of monsters that comprise the monster party, as well as hit points, armor class, and other + statistics are determined by the given MonsterStatBlock. + + Args: + monster_stat_block (MonsterStatBlock): The stat block for the monsters in the party. + """ + self.monsters = [ + Monster(monster_stat_block) + for _ in range( + roll_dice(monster_stat_block.num_appearing).total_with_modifier + ) + ] + self.treasure = self._get_treasure(monster_stat_block.treasure_type) + + def _get_treasure(self, treasure_type: TreasureType): + """Get the treasure for the monster party based on the treasure type. + + NOT YET IMPLEMENTED + + Args: + treasure_type (TreasureType): The type of treasure to get. + + Returns: + list: A list of the items to be awarded as treasure to the party. + """ + treasure_items = [] + if treasure_type == TreasureType.NONE: + return None + elif treasure_type == TreasureType.A: + pass + elif treasure_type == TreasureType.B: + pass + elif treasure_type == TreasureType.C: + pass + elif treasure_type == TreasureType.D: + pass + elif treasure_type == TreasureType.E: + pass + elif treasure_type == TreasureType.F: + pass + elif treasure_type == TreasureType.G: + pass + elif treasure_type == TreasureType.H: + pass + elif treasure_type == TreasureType.I: + pass + elif treasure_type == TreasureType.J: + pass + elif treasure_type == TreasureType.K: + pass + elif treasure_type == TreasureType.L: + pass + elif treasure_type == TreasureType.M: + pass + elif treasure_type == TreasureType.N: + pass + elif treasure_type == TreasureType.O: + pass + elif treasure_type == TreasureType.P: + pass + elif treasure_type == TreasureType.Q: + pass + elif treasure_type == TreasureType.R: + pass + elif treasure_type == TreasureType.S: + pass + elif treasure_type == TreasureType.T: + pass + elif treasure_type == TreasureType.U: + pass + elif treasure_type == TreasureType.V: + pass - pass \ No newline at end of file + return treasure_items + + @property + def is_alive(self): + """Get whether the monster party is alive. + + Returns: + int: True if the monster party has more than 0 hit points, otherwise False. + """ + return any(monster.is_alive for monster in self.monsters) diff --git a/osrlib/osrlib/party.py b/osrlib/osrlib/party.py index c2c165d..d230883 100644 --- a/osrlib/osrlib/party.py +++ b/osrlib/osrlib/party.py @@ -4,7 +4,7 @@ from typing import List from osrlib import player_character, game_manager as gm -from osrlib.character_classes import CharacterClassType +from osrlib.enums import CharacterClassType from osrlib.item_factories import equip_party diff --git a/osrlib/osrlib/player_character.py b/osrlib/osrlib/player_character.py index 2e4c97d..3019c46 100644 --- a/osrlib/osrlib/player_character.py +++ b/osrlib/osrlib/player_character.py @@ -1,4 +1,5 @@ """This module contains the PlayerCharacter class.""" +from enum import Enum import osrlib.ability from osrlib.ability import ( AbilityType, @@ -10,17 +11,26 @@ Strength, Wisdom, ) -from osrlib import ( - dice_roller, +from osrlib.character_classes import ( CharacterClass, CharacterClassType, ClassLevel, - Inventory, - Item, +) +from osrlib.inventory import Inventory +from osrlib import ( + dice_roller, game_manager as gm, ) +class Alignment(Enum): + """Represents the alignment of a player character (PC) or monster.""" + + LAWFUL = "Lawful" + NEUTRAL = "Neutral" + CHAOTIC = "Chaotic" + + class PlayerCharacter: """Represents a player character (PC) in the game. @@ -40,7 +50,7 @@ def __init__( """Initialize a new PlayerCharacter (PC) instance.""" self.name = name self.abilities = {} - self.roll_abilities() # TODO: Should NOT roll abilities when loading a saved character + self.roll_abilities() # TODO: Should NOT roll abilities when loading a saved character self.character_class = None self.set_character_class(character_class_type, level) @@ -106,6 +116,14 @@ def get_ability_roll(self): roll = dice_roller.roll_dice("4d6", drop_lowest=True) return roll.total + def get_initiative_roll(self): + """Rolls a 1d6, adds the character's Dexterity modifier, and returns the total.""" + modifier_value = self.abilities[AbilityType.DEXTERITY].modifiers[ + ModifierType.INITIATIVE + ] + roll = dice_roller.roll_dice("1d6", modifier_value=modifier_value) + return roll.total_with_modifier + def set_character_class( self, character_class_type: CharacterClassType, level: int = 1 ): diff --git a/osrlib/osrlib/saving_throws.py b/osrlib/osrlib/saving_throws.py new file mode 100644 index 0000000..c6a315b --- /dev/null +++ b/osrlib/osrlib/saving_throws.py @@ -0,0 +1,193 @@ +from osrlib.enums import CharacterClassType +from osrlib.combat import AttackType + +saving_throws = { + CharacterClassType.CLERIC: { + range(1, 5): { + AttackType.DEATH_RAY_POISON: 11, + AttackType.MAGIC_WANDS: 12, + AttackType.PARALYSIS_TURN_TO_STONE: 14, + AttackType.DRAGON_BREATH: 16, + AttackType.RODS_STAVES_SPELLS: 15 + }, + range(5, 9): { + AttackType.DEATH_RAY_POISON: 9, + AttackType.MAGIC_WANDS: 10, + AttackType.PARALYSIS_TURN_TO_STONE: 12, + AttackType.DRAGON_BREATH: 14, + AttackType.RODS_STAVES_SPELLS: 12 + }, + range(9, 13): { + AttackType.DEATH_RAY_POISON: 6, + AttackType.MAGIC_WANDS: 7, + AttackType.PARALYSIS_TURN_TO_STONE: 9, + AttackType.DRAGON_BREATH: 11, + AttackType.RODS_STAVES_SPELLS: 9 + }, + range(13, 17): { + AttackType.DEATH_RAY_POISON: 3, + AttackType.MAGIC_WANDS: 5, + AttackType.PARALYSIS_TURN_TO_STONE: 7, + AttackType.DRAGON_BREATH: 8, + AttackType.RODS_STAVES_SPELLS: 7 + }, + }, + CharacterClassType.ELF: { + range(1, 4): { + AttackType.DEATH_RAY_POISON: 12, + AttackType.MAGIC_WANDS: 13, + AttackType.PARALYSIS_TURN_TO_STONE: 13, + AttackType.DRAGON_BREATH: 15, + AttackType.RODS_STAVES_SPELLS: 15 + }, + range(4, 7): { + AttackType.DEATH_RAY_POISON: 10, + AttackType.MAGIC_WANDS: 11, + AttackType.PARALYSIS_TURN_TO_STONE: 11, + AttackType.DRAGON_BREATH: 13, + AttackType.RODS_STAVES_SPELLS: 12 + }, + range(7, 10): { + AttackType.DEATH_RAY_POISON: 8, + AttackType.MAGIC_WANDS: 9, + AttackType.PARALYSIS_TURN_TO_STONE: 9, + AttackType.DRAGON_BREATH: 10, + AttackType.RODS_STAVES_SPELLS: 10 + }, + range(10,11): { + AttackType.DEATH_RAY_POISON: 6, + AttackType.MAGIC_WANDS: 7, + AttackType.PARALYSIS_TURN_TO_STONE: 8, + AttackType.DRAGON_BREATH: 8, + AttackType.RODS_STAVES_SPELLS: 8 + }, + }, + CharacterClassType.THIEF: { + range(1, 5): { + AttackType.DEATH_RAY_POISON: 13, + AttackType.MAGIC_WANDS: 14, + AttackType.PARALYSIS_TURN_TO_STONE: 13, + AttackType.DRAGON_BREATH: 16, + AttackType.RODS_STAVES_SPELLS: 15 + }, + range(5, 9): { + AttackType.DEATH_RAY_POISON: 12, + AttackType.MAGIC_WANDS: 13, + AttackType.PARALYSIS_TURN_TO_STONE: 11, + AttackType.DRAGON_BREATH: 14, + AttackType.RODS_STAVES_SPELLS: 13 + }, + range(9, 13): { + AttackType.DEATH_RAY_POISON: 10, + AttackType.MAGIC_WANDS: 11, + AttackType.PARALYSIS_TURN_TO_STONE: 9, + AttackType.DRAGON_BREATH: 12, + AttackType.RODS_STAVES_SPELLS: 10 + }, + range(13, 17): { + AttackType.DEATH_RAY_POISON: 8, + AttackType.MAGIC_WANDS: 9, + AttackType.PARALYSIS_TURN_TO_STONE: 7, + AttackType.DRAGON_BREATH: 10, + AttackType.RODS_STAVES_SPELLS: 8 + }, + }, + CharacterClassType.MAGIC_USER: { + range(1, 6): { + AttackType.DEATH_RAY_POISON: 13, + AttackType.MAGIC_WANDS: 14, + AttackType.PARALYSIS_TURN_TO_STONE: 13, + AttackType.DRAGON_BREATH: 16, + AttackType.RODS_STAVES_SPELLS: 15 + }, + range(6, 11): { + AttackType.DEATH_RAY_POISON: 11, + AttackType.MAGIC_WANDS: 12, + AttackType.PARALYSIS_TURN_TO_STONE: 11, + AttackType.DRAGON_BREATH: 14, + AttackType.RODS_STAVES_SPELLS: 12 + }, + range(11, 16): { + AttackType.DEATH_RAY_POISON: 8, + AttackType.MAGIC_WANDS: 9, + AttackType.PARALYSIS_TURN_TO_STONE: 8, + AttackType.DRAGON_BREATH: 11, + AttackType.RODS_STAVES_SPELLS: 8 + }, + }, + CharacterClassType.FIGHTER: { + range(1, 4): { + AttackType.DEATH_RAY_POISON: 12, + AttackType.MAGIC_WANDS: 13, + AttackType.PARALYSIS_TURN_TO_STONE: 14, + AttackType.DRAGON_BREATH: 15, + AttackType.RODS_STAVES_SPELLS: 16 + }, + range(4, 7): { + AttackType.DEATH_RAY_POISON: 10, + AttackType.MAGIC_WANDS: 11, + AttackType.PARALYSIS_TURN_TO_STONE: 12, + AttackType.DRAGON_BREATH: 13, + AttackType.RODS_STAVES_SPELLS: 14 + }, + range(7, 10): { + AttackType.DEATH_RAY_POISON: 8, + AttackType.MAGIC_WANDS: 9, + AttackType.PARALYSIS_TURN_TO_STONE: 10, + AttackType.DRAGON_BREATH: 10, + AttackType.RODS_STAVES_SPELLS: 12 + }, + range(10, 13): { + AttackType.DEATH_RAY_POISON: 6, + AttackType.MAGIC_WANDS: 7, + AttackType.PARALYSIS_TURN_TO_STONE: 8, + AttackType.DRAGON_BREATH: 8, + AttackType.RODS_STAVES_SPELLS: 10 + }, + range(13, 16): { + AttackType.DEATH_RAY_POISON: 4, + AttackType.MAGIC_WANDS: 5, + AttackType.PARALYSIS_TURN_TO_STONE: 6, + AttackType.DRAGON_BREATH: 5, + AttackType.RODS_STAVES_SPELLS: 8 + }, + }, + CharacterClassType.DWARF: { + range(1, 4): { + AttackType.DEATH_RAY_POISON: 8, + AttackType.MAGIC_WANDS: 9, + AttackType.PARALYSIS_TURN_TO_STONE: 10, + AttackType.DRAGON_BREATH: 13, + AttackType.RODS_STAVES_SPELLS: 12 + }, + range(4, 7): { + AttackType.DEATH_RAY_POISON: 6, + AttackType.MAGIC_WANDS: 7, + AttackType.PARALYSIS_TURN_TO_STONE: 8, + AttackType.DRAGON_BREATH: 10, + AttackType.RODS_STAVES_SPELLS: 10 + }, + range(7, 10): { + AttackType.DEATH_RAY_POISON: 4, + AttackType.MAGIC_WANDS: 5, + AttackType.PARALYSIS_TURN_TO_STONE: 6, + AttackType.DRAGON_BREATH: 7, + AttackType.RODS_STAVES_SPELLS: 8 + }, + range(10, 13): { + AttackType.DEATH_RAY_POISON: 2, + AttackType.MAGIC_WANDS: 3, + AttackType.PARALYSIS_TURN_TO_STONE: 4, + AttackType.DRAGON_BREATH: 4, + AttackType.RODS_STAVES_SPELLS: 6 + }, + }, +} + +saving_throws[CharacterClassType.HALFLING] = saving_throws[CharacterClassType.DWARF].copy() + +saving_throws[CharacterClassType.COMMONER] = { + level_range: {save: val + 2 for save, val in values.items()} + for level_range, values in saving_throws[CharacterClassType.FIGHTER].items() + if level_range.stop <= 11 +} diff --git a/osrlib/osrlib/treasure.py b/osrlib/osrlib/treasure.py new file mode 100644 index 0000000..aedb697 --- /dev/null +++ b/osrlib/osrlib/treasure.py @@ -0,0 +1,104 @@ +from enum import Enum +from osrlib.dice_roller import roll_dice +from osrlib.item import Item, ItemType, Weapon, Armor + +class TreasureType(Enum): + """Represents the treasure type of a treasure.""" + + NONE = "None" + A = "A" + B = "B" + C = "C" + D = "D" + E = "E" + F = "F" + G = "G" + H = "H" + I = "I" + J = "J" + K = "K" + L = "L" + M = "M" + N = "N" + O = "O" + P = "P" + Q = "Q" + R = "R" + S = "S" + T = "T" + U = "U" + V = "V" + W = "W" + X = "X" + Y = "Y" + Z = "Z" + +def get_treasure(treasure_type: TreasureType): + """Get a collection of items appropriate for the specified treasure type. + + Typical use of this method is to pass it the treasure_type attribute value of a MonsterStatBlock instance + when determining the treasure to award the party for defeating a monster. + + Args: + treasure_type (TreasureType): The type of treasure to get. + + Returns: + list: A list of the items to be awarded as treasure to the party. + """ + treasure_items = [] + if treasure_type == TreasureType.NONE: + return None + elif treasure_type == TreasureType.A: + pass + elif treasure_type == TreasureType.B: + pass + elif treasure_type == TreasureType.C: + pass + elif treasure_type == TreasureType.D: + pass + elif treasure_type == TreasureType.E: + pass + elif treasure_type == TreasureType.F: + pass + elif treasure_type == TreasureType.G: + pass + elif treasure_type == TreasureType.H: + pass + elif treasure_type == TreasureType.I: + pass + elif treasure_type == TreasureType.J: + pass + elif treasure_type == TreasureType.K: + pass + elif treasure_type == TreasureType.L: + pass + elif treasure_type == TreasureType.M: + pass + elif treasure_type == TreasureType.N: + pass + elif treasure_type == TreasureType.O: + pass + elif treasure_type == TreasureType.P: + pass + elif treasure_type == TreasureType.Q: + pass + elif treasure_type == TreasureType.R: + pass + elif treasure_type == TreasureType.S: + pass + elif treasure_type == TreasureType.T: + pass + elif treasure_type == TreasureType.U: + pass + elif treasure_type == TreasureType.V: + pass + elif treasure_type == TreasureType.W: + pass + elif treasure_type == TreasureType.X: + pass + elif treasure_type == TreasureType.Y: + pass + elif treasure_type == TreasureType.Z: + pass + + return treasure_items \ No newline at end of file diff --git a/tests/test_unit_character_classes.py b/tests/test_unit_character_classes.py index d21f681..bc64720 100644 --- a/tests/test_unit_character_classes.py +++ b/tests/test_unit_character_classes.py @@ -2,7 +2,8 @@ import pytest -from osrlib.character_classes import CharacterClassType, class_levels +from osrlib.enums import CharacterClassType +from osrlib.character_classes import class_levels from osrlib.player_character import PlayerCharacter logger = logging.getLogger(__name__) diff --git a/tests/test_unit_dungeon.py b/tests/test_unit_dungeon.py index a2d5216..7635699 100644 --- a/tests/test_unit_dungeon.py +++ b/tests/test_unit_dungeon.py @@ -121,3 +121,24 @@ def test_random_dungeon(): # Validate Dungeon assert random_dungeon.validate_location_connections() + + +def test_dungeon_graph_integrity(): + dungeon = Dungeon.get_random_dungeon(num_locations=20) + + def dfs(location_id, visited): + if location_id not in visited: + visited.add(location_id) + location = dungeon.get_location(location_id) + for exit in location.exits: + dfs(exit.destination, visited) + + all_locations_reachable = True + for location in dungeon.locations: + visited = set() + dfs(location.id, visited) + if len(visited) != len(dungeon.locations): + all_locations_reachable = False + break # If one fails, no need to continue testing others + + assert all_locations_reachable, "Not all locations are reachable from every other location." diff --git a/tests/test_unit_item.py b/tests/test_unit_item.py index 6923ae3..bad15fc 100644 --- a/tests/test_unit_item.py +++ b/tests/test_unit_item.py @@ -2,7 +2,7 @@ import pytest -from osrlib.character_classes import CharacterClassType +from osrlib.enums import CharacterClassType from osrlib.item import ( ItemAlreadyHasOwnerError, ItemAlreadyInInventoryError,