import re from random import randint from enum_list import DamageType, DamageMod, ItemType, Slots, SaveType from spell_list import SPELL_LISTS, get_spell_by_name def roll_dice(dice_str): """Rolls a dice string like '1d6', '3d4+3', '2d10-1' and returns the total.""" if not dice_str: return 0 match = re.match(r"(\d+)d(\d+)([+-]\d+)?", dice_str.strip()) if not match: return 0 num, size, modifier = match.groups() total = sum(randint(1, int(size)) for _ in range(int(num))) if modifier: total += int(modifier) return total # Define Item classes here, as they are fundamental building blocks class Item: """The base class for all items""" def __init__(self, name, description, value, magical, itemType, attunement, weight=0): self.name = name self.description = description self.value = value self.magical = magical self.itemType = itemType self.attunement = attunement self.weight = weight # in pounds, per SRD carrying capacity rules def __repr__(self): return f"{self.name}\n=====\n{self.description}\nValue: {self.value}\n" class Money(Item): """The currency item used in the world of Kanjin""" def __init__(self, name, amt, magical, ItemType): self.name = name self.amt = amt self.magical = magical self.itemType = ItemType super().__init__(name=self.name, description=f"A small round coin made of {self.name.lower()} " f"with the imperial city logo stamped on the face.", value=self.amt, magical=self.magical, itemType=ItemType.Money, attunement=False, weight=0.02) # SRD: 50 coins weigh 1 lb, regardless of denomination class Weapon(Item): """The base class for all weapons""" def __init__(self, name, description, value, slot, damage1H, damage2H, versatiledmg, light, finesse, dmgType, dmgMod, versatile, thrown, ItemType, magical, attunement, weight=0): self.slot = slot self.damage1H = damage1H self.damage2H = damage2H self.versatiledmg = versatiledmg self.light = light self.finesse = finesse self.dmgType = dmgType self.dmgMod = dmgMod self.versatile = versatile self.thrown = thrown self.itemType = ItemType super().__init__(name, description, value, magical, ItemType, attunement, weight) def __str__(self): if self.damage2H is None and self.damage1H is not None: return f"{self.name}\n=====" \ f"\n{self.description}" \ f"\nValue: {self.value}" \ f"\nDamage: One Handed - {self.damage1H}" \ f"\nDamage Type: {self.dmgType.name.title()}" \ f"\nMagical: {self.magical}" \ f"\nAttunement: {'Required' if self.attunement else 'Not Required'}" elif self.damage1H is None and self.damage2H is not None: return f"{self.name}\n=====" \ f"\n{self.description}" \ f"\nValue: {self.value}" \ f"\nDamage: Two Handed - {self.damage2H}" \ f"\nDamage Type: {self.dmgType.name.title()}" \ f"\nMagical: {self.magical}" \ f"\nAttunement: {'Required' if self.attunement else 'Not Required'}" elif self.versatile: return f"{self.name}\n=====" \ f"\n{self.description}" \ f"\nValue: {self.value}" \ f"\nDamage: Versatile - {self.versatiledmg}" \ f"\nDamage Type: {self.dmgType.name.title()}" \ f"\nMagical: {self.magical}" \ f"\nAttunement: {'Required' if self.attunement else 'Not Required'}" else: return f"{self.name}\n=====" \ f"\n{self.description}\n" \ f"Value: {self.value}\n" \ f"Magical: {self.magical}\n" \ f"Attunement: {'Required' if self.attunement else 'Not Required'}" class Armor(Item): """The base class for all armor""" def __init__(self, name, description, value, slot, grade, ac, disadvantage, dmgRes, ItemType, magical, attunement, weight=0): self.slot = slot self.grade = grade self.ac = ac self.stealthDis = disadvantage self.dmgRes = dmgRes self.itemType = ItemType super().__init__(name, description, value, magical, ItemType, attunement, weight) def __repr__(self): if self.stealthDis: return f"{self.name}\n" \ f"=====\n" \ f"{self.description}\n" \ f"Value: {self.value}\n" \ f"AC: {self.ac}\n" \ f"Magical: {self.magical}" elif not self.stealthDis: return f"{self.name}\n=====" \ f"\n{self.description}\n" \ f"Value: {self.value}\n" \ f"AC: {self.ac}\n" \ f"Disadvantage on Stealth checks: {self.stealthDis}\n" \ f"Magical: {self.magical}\n" return None class Inventory: def __init__(self): # Dictionary to store item objects as keys and their data (count) as values self.items = {} # Dictionary to store equipped items by slot self.equipped_items = { Slots.MainHand: None, Slots.OffHand: None, Slots.TwoHanded: None, Slots.Helm: None, Slots.Armor: None, Slots.Wrists: None, Slots.Feet: None, Slots.Neck: None, Slots.Cloak: None, Slots.LeftRing: None, Slots.RightRing: None, Slots.Other: None, # For miscellaneous equipped items Slots.Attunement: 0 # Counter for attuned items } def _get_slot_display_name(self, slot_val): """Helper to get a user-friendly string name for a slot, handling both Enum and string inputs.""" if isinstance(slot_val, Slots): return slot_val.name.replace('TwoHanded', 'Two Handed').replace('MainHand', 'Main Hand').replace('OffHand', 'Off Hand').replace('LeftRing', 'Left Ring').replace( 'RightRing', 'Right Ring') elif isinstance(slot_val, str): # If it's already a string, assume it's the name and format it return (slot_val.replace('TwoHanded', 'Two Handed').replace('MainHand', 'Main Hand') .replace('OffHand','Off Hand').replace('LeftRing', 'Left Ring') .replace('RightRing', 'Right Ring')) return str(slot_val) # Fallback for unexpected types def add_item(self, item, count=1, silent=False): """Adds an item to the inventory.""" if item in self.items: self.items[item]["Count"] += count if not silent: print(f"Added {count} more {item.name}. Total: {self.items[item]['Count']}.") else: self.items[item] = {"Count": count, "object": item.itemType} if not silent: print(f"Added {count} {item.name} to inventory.") def remove_item(self, item, count=1): """Removes an item from the inventory.""" if item in self.items: if self.items[item]["Count"] <= count: print(f"Removed all {self.items[item]['Count']} {item.name} from inventory.") del self.items[item] # If the item was equipped, unequip it for slot, equipped_item in self.equipped_items.items(): if equipped_item == item: self.unequip_item(slot) else: self.items[item]["Count"] -= count print(f"Removed {count} {item.name} from inventory. Remaining: {self.items[item]['Count']}.") else: print(f"{item.name} not found in inventory.") def current_inventory(self): """Prints a list of items in your backpack that aren't equipped to your person.""" print(f'You are currently carrying:') found_un_equipped = False for item_obj, item_data in self.items.items(): # Check if the item is in the backpack AND not currently equipped in any slot if item_obj not in self.equipped_items.values(): found_un_equipped = True count = item_data["Count"] print(f'{item_obj.name} x {count}\n' f' {item_obj.description}\n') if not found_un_equipped: print(" Your rucksack is empty.") def current_equipment(self): """Prints a list of items that you have equipped in your slots.""" print(f'You have equipped:') equipment_found = False for slot, equipment in self.equipped_items.items(): if equipment is not None and slot != Slots.Attunement: # Don't print the attunement counter equipment_found = True slot_name = slot.name.replace("TwoHanded", "Two Handed").replace("MainHand", "Main Hand").replace("OffHand", "Off Hand").replace("LeftRing", "Left Ring").replace("RightRing", "Right Ring") if equipment.itemType == ItemType.Weapon: if equipment.versatile: print(f'{slot_name}: {equipment.name} - {equipment.versatiledmg}\n' f' {equipment.description}\n') elif equipment.damage1H is not None: print(f'{slot_name}: {equipment.name} - {equipment.damage1H}\n' f' {equipment.description}\n') elif equipment.damage2H is not None: print(f'{slot_name}: {equipment.name} - {equipment.damage2H}\n' f' {equipment.description}\n') elif equipment.itemType == ItemType.Armor: print(f'{slot_name}: {equipment.name} - AC {equipment.ac}\n' f' {equipment.description}\n') elif equipment.itemType == ItemType.Item: print(f'{slot_name}: {equipment.name}\n' f' {equipment.description}\n') if not equipment_found: print(" Nothing equipped.") print(f"Attunement slots used: {self.equipped_items[Slots.Attunement]}/3") def equip_item(self, item_obj, slot: Slots, silent=False): """Equips an item to a specific slot.""" if item_obj not in self.items or self.items[item_obj]["Count"] == 0: if not silent: print(f"You don't have {item_obj.name} to equip.") return # Check attunement limits before equipping (moved up for early exit) if item_obj.attunement and self.equipped_items[Slots.Attunement] >= 3: if not silent: print(f"You cannot equip {item_obj.name}. You have reached your attunement limit (3/3).") return # Handle unequipping existing item in the target slot first if self.equipped_items[slot] is not None: self.unequip_item(slot) # This will print its own unequip message # Now, handle equipping based on item type and specific properties if item_obj.itemType == ItemType.Weapon: if item_obj.slot == Slots.TwoHanded: # If equipping a two-handed weapon, unequip anything in main/off-hand if self.equipped_items[Slots.MainHand] is not None: self.unequip_item(Slots.MainHand, silent=True) # Silent unequip if self.equipped_items[Slots.OffHand] is not None: self.unequip_item(Slots.OffHand, silent=True) # Silent unequip self.equipped_items[Slots.TwoHanded] = item_obj if not silent: print(f"Equipped {item_obj.name} to {self._get_slot_display_name(Slots.TwoHanded)}.") elif item_obj.versatile: # If equipping a versatile weapon, it goes in MainHand, and potentially affects OffHand if self.equipped_items[Slots.TwoHanded] is not None: self.unequip_item(Slots.TwoHanded, silent=True) # Unequip two-handed if present silently self.equipped_items[Slots.MainHand] = item_obj if not silent: print(f"Equipped {item_obj.name} to {self._get_slot_display_name(Slots.MainHand)}.") else: # Standard one-handed weapon (or other weapon types not specifically handled above) self.equipped_items[slot] = item_obj # Equip to the specified slot (MainHand or OffHand) if not silent: print(f"Equipped {item_obj.name} to {self._get_slot_display_name(slot)}.") elif item_obj.itemType == ItemType.Armor: # For armor, ensure the target slot is appropriate for armor (e.g., Chest, Helm) # The Armor class now has a 'slot' attribute, so we can use that for validation/assignment if item_obj.slot == slot: # Ensure the item's intended slot matches the target slot self.equipped_items[slot] = item_obj if not silent: print(f"Equipped {item_obj.name} to {self._get_slot_display_name(slot)}.") else: if not silent: print(f"Cannot equip {item_obj.name} to {self._get_slot_display_name(slot)}. It belongs in the {self._get_slot_display_name(item_obj.slot)} slot.") return # Exit if slot mismatch elif item_obj.itemType == ItemType.Item: # For general items (rings, cloaks, etc.), equip to the specified slot self.equipped_items[slot] = item_obj if not silent: print(f"Equipped {item_obj.name} to {self._get_slot_display_name(slot)}.") else: if not silent: print(f"Cannot equip {item_obj.name}. Unknown item type or invalid slot for this item.") return # Exit if item type is not recognized for equipping self.update_attunement() # Update attunement after successful equip def unequip_item(self, slot: Slots, silent=False): """Unequips an item from a specific slot.""" if self.equipped_items[slot] is not None: unequipped_item = self.equipped_items[slot] self.equipped_items[slot] = None if not silent: print(f"Unequipped {unequipped_item.name} from {self._get_slot_display_name(slot)}.") # Use helper function self.update_attunement() else: if not silent: print(f"Nothing is equipped in {self._get_slot_display_name(slot)}.") # Use helper function def update_attunement(self): """Recalculates the number of attuned items.""" attune_count = 0 for slot, item in self.equipped_items.items(): if slot != Slots.Attunement and item is not None and item.attunement: attune_count += 1 self.equipped_items[Slots.Attunement] = attune_count # ----- Weight / Encumbrance (SRD carrying capacity) ----- def total_weight(self): """Sums the weight of everything carried, including equipped gear.""" return sum(item.weight * data["Count"] for item, data in self.items.items()) def carrying_capacity(self, strength_score): """SRD: your carrying capacity is your Strength score multiplied by 15.""" return strength_score * 15 def encumbrance_status(self, strength_score): """Returns (status_str, weight, capacity) using the SRD variant encumbrance thresholds.""" weight = self.total_weight() capacity = self.carrying_capacity(strength_score) heavily_encumbered_at = strength_score * 10 encumbered_at = strength_score * 5 if weight > capacity: status = "Over Capacity! You cannot carry this much." elif weight > heavily_encumbered_at: status = "Heavily Encumbered (speed -20 ft, disadvantage on Strength/Dexterity/Constitution checks, attacks, and saves)" elif weight > encumbered_at: status = "Encumbered (speed -10 ft)" else: status = "Unencumbered" return status, weight, capacity def print_carry_report(self, strength_score): """Prints a human-readable summary of current carrying weight/capacity.""" status, weight, capacity = self.encumbrance_status(strength_score) print(f"Carrying {weight:.2f} lb / {capacity} lb capacity.") print(f"Status: {status}") # ----- Currency ----- def _coin_counts(self): """Returns a dict of coin name -> count currently held, e.g. {'Gold': 12}.""" counts = {} for item, data in self.items.items(): if item.itemType == ItemType.Money: counts[item.name] = data["Count"] return counts def total_currency_in_gp(self): """Converts all held coinage to a single gold-piece value (SRD: 1gp = 10sp = 100cp).""" coins = self._coin_counts() gp = coins.get("Gold", 0) sp = coins.get("Silver", 0) cp = coins.get("Copper", 0) return gp + (sp / 10) + (cp / 100) def print_currency(self): """Prints a breakdown of held coinage and its total gold-piece value.""" coins = self._coin_counts() if not coins: print("You have no coins.") return parts = [f"{count} {name}" for name, count in coins.items() if count > 0] print(f"You are carrying: {', '.join(parts) if parts else 'no coins'}.") print(f"Total value: {self.total_currency_in_gp():.2f} gp") # --- Item Instances (Moved here for clarity, but still defined once) --- # Different types of coins GP1 = Money("Gold", 1, False, ItemType.Money) SP1 = Money("Silver", 1, False, ItemType.Money) CP1 = Money("Copper", 1, False, ItemType.Money) # starting equip rock = Weapon( name="Rock", description="A fist-sized rock, suitable for bludgeoning.", value=None, slot=Slots.MainHand, damage1H='1d6', damage2H=None, versatiledmg=None, light=True, finesse=False, dmgType=DamageType.Bludgeoning, dmgMod=DamageMod.Strength, versatile=False, thrown=True, magical=False, ItemType=ItemType.Weapon, attunement=False, weight=2 ) dagger = Weapon( name="Dagger", description="Pointy stabby-stab", value=None, slot=Slots.MainHand, damage1H='1d4', damage2H=None, versatiledmg=None, light=True, finesse=True, dmgType=DamageType.Piercing, dmgMod=DamageMod.Dexterity, versatile=False, thrown=True, magical=False, ItemType=ItemType.Weapon, attunement=False, weight=1 ) polearm = Weapon( name="Polearm", description="Long Pointy stabby-stab", value=None, slot=Slots.TwoHanded, damage1H=None, damage2H="1d10", # Polearms are typically 1d10 versatiledmg=None, light=False, finesse=False, dmgType=DamageType.Piercing, dmgMod=DamageMod.Strength, versatile=False, thrown=False, # Polearms are not typically thrown magical=False, ItemType=ItemType.Weapon, attunement=False, weight=6 ) tornRags = Armor( name="Torn Rags", description="A ripped and worn-out outfit.", value=None, slot=Slots.Armor, grade="Light", ac=10, # Base AC for light armor without proficiency is 10 + Dex mod disadvantage=False, # Light armor usually doesn't give disadvantage dmgRes="None", magical=False, ItemType=ItemType.Armor, attunement=False, weight=2 ) paper = Item( name="Paper", description="A blank piece of paper", value=None, magical=False, itemType=ItemType.Item, attunement=False, weight=0 ) class Player: """Character Creation""" def __init__(self, name, gender, age, race, class_): # Identity self.name = name self.gender = gender self.age = age self.race = race # `class_` (not `class`) because `class` is a reserved Python keyword. # See the NOTE ON NAMING comment at the top of class_list.py for the full convention. self.class_ = class_ # Defence self.ac = 0 # This will be calculated based on equipped armor self.elemental_resistance = {DamageType.Fire: False, DamageType.Cold: False, DamageType.Lightning: False, DamageType.Thunder: False, DamageType.Poison: False, DamageType.Acid: False, DamageType.Necrotic: False, DamageType.Radiant: False, DamageType.Force: False, DamageType.Psychic: False } self.physical_resistance = {DamageType.Slashing: False, DamageType.Bludgeoning: False, DamageType.Piercing: False} # Levels self.level = 1 self.exp = 0 self.maxEXP = 100 # Equipment and Inventory self.inventory = Inventory() # Each player gets their own inventory instance # Proficiency bonus (SRD: +2 at levels 1-4) self.proficiency_bonus = 2 # --- Spellcasting (SRD) --- # None of these are populated until initialize_spellcasting() runs, since they # depend on ability modifiers, which are calculated later during character creation. self.spellcasting_ability = class_.spellcasting_ability # e.g. "Intelligence", or None self.cantrips_known = [] # list of Spell objects, always available, no slot cost self.spells_known = [] # Wizard: spellbook contents. Cleric: full accessible list. self.spells_prepared = [] # subset of spells_known currently prepared/castable self.spell_slots_max = dict(class_.spell_slots) # {level: max_slots} self.spell_slots_current = dict(class_.spell_slots) # {level: slots_remaining} # Ability Scores self.stats = {"Strength": 0, "Dexterity": 0, "Constitution": 0, "Intelligence": 0, "Wisdom": 0, "Charisma": 0} # Ability Modifiers self.mods = {"Strength": 0, "Dexterity": 0, "Constitution": 0, "Intelligence": 0, "Wisdom": 0, "Charisma": 0} # Initial items and equipment (now with silent=True) self.inventory.add_item(rock, 1, silent=True) self.inventory.add_item(tornRags, 1, silent=True) # Equip initial items (using the inventory's equip method) self.inventory.equip_item(rock, Slots.MainHand, silent=True) self.inventory.equip_item(tornRags, Slots.Armor, silent=True) def getModifier(self): """Floor calculation to work out skill check modifiers""" self.mods["Strength"] = -5 + self.stats["Strength"] // 2 self.mods["Dexterity"] = -5 + self.stats["Dexterity"] // 2 self.mods["Constitution"] = -5 + self.stats["Constitution"] // 2 self.mods["Intelligence"] = -5 + self.stats["Intelligence"] // 2 self.mods["Wisdom"] = -5 + self.stats["Wisdom"] // 2 self.mods["Charisma"] = -5 + self.stats["Charisma"] // 2 def set_stats_race(self): """Applies racial ability score increases.""" # Access name attribute of Race object if self.race.name.lower() == "human": print("As a Human, two different ability scores of your choice increase by 1.") selection1 = input("Choose the first ability score to increase by 1.\n" "1) Strength\n" "2) Dexterity\n" "3) Constitution\n" "4) Intelligence\n" "5) Wisdom\n" "6) Charisma\n\n >> ").lower() while True: if selection1 in ("1", "strength"): self.stats["Strength"] += 1 break elif selection1 in ("2", "dexterity"): self.stats["Dexterity"] += 1 break elif selection1 in ("3", "constitution"): self.stats["Constitution"] += 1 break elif selection1 in ("4", "intelligence"): self.stats["Intelligence"] += 1 break elif selection1 in ("5", "wisdom"): self.stats["Wisdom"] += 1 break elif selection1 in ("6", "charisma"): self.stats["Charisma"] += 1 break else: selection1 = input("Sorry, I didn't recognise that. Please select from the following:\n" "1) Strength\n" "2) Dexterity\n" "3) Constitution\n" "4) Intelligence\n" "5) Wisdom\n" "6) Charisma\n >> ").lower() continue selection2 = input("Choose the second ability score to increase by 1.\n" "1) Strength\n" "2) Dexterity\n" "3) Constitution\n" "4) Intelligence\n" "5) Wisdom\n" "6) Charisma\n\n >> ").lower() while True: if selection2 in ("1", "strength"): self.stats["Strength"] += 1 break elif selection2 in ("2", "dexterity"): self.stats["Dexterity"] += 1 break elif selection2 in ("3", "constitution"): self.stats["Constitution"] += 1 break elif selection2 in ("4", "intelligence"): self.stats["Intelligence"] += 1 break elif selection2 in ("5", "wisdom"): self.stats["Wisdom"] += 1 break elif selection2 in ("6", "charisma"): self.stats["Charisma"] += 1 break else: selection2 = input("Sorry, I didn't recognise that. Please select from the following:\n" "1) Strength\n" "2) Dexterity\n" "3) Constitution\n" "4) Intelligence\n" "5) Wisdom\n" "6) Charisma\n >> ").lower() continue elif self.race.name.lower() == "elf": print("As an Elf, your dexterity increases by 2.") self.stats["Dexterity"] += 2 elif self.race.name.lower() == "dwarf": print("As a Dwarf, your constitution increases by 2.") self.stats["Constitution"] += 2 def set_stats_class(self): """Applies class-specific stats like hit points.""" # Using self.class_.name to access the class name from the CharacterClass object if self.class_.name.lower() == "barbarian": self.hitDie = randint(self.class_.hitdie[0], self.class_.hitdie[1]) # Use class's hitdie range self.maxHP = (self.class_.hitdie[1] + self.mods["Constitution"]) # Use max hitdie value for initial HP self.currentHP = self.maxHP elif self.class_.name.lower() == "cleric": self.hitDie = randint(self.class_.hitdie[0], self.class_.hitdie[1]) self.maxHP = (self.class_.hitdie[1] + self.mods["Constitution"]) self.currentHP = self.maxHP elif self.class_.name.lower() == "wizard": self.hitDie = randint(self.class_.hitdie[0], self.class_.hitdie[1]) self.maxHP = (self.class_.hitdie[1] + self.mods["Constitution"]) self.currentHP = self.maxHP self.initialize_spellcasting() # ----- Magic system (SRD) ----- def initialize_spellcasting(self): """Grants starting cantrips and known/preparable spells for casters. Non-casters no-op.""" if not self.class_.is_caster: return class_name = self.class_.name class_spells = SPELL_LISTS.get(class_name, {}) # Cantrips: granted automatically up to the class's cantrips_known count. available_cantrips = class_spells.get(0, []) self.cantrips_known = list(available_cantrips[:self.class_.cantrips_known]) # Known/preparable leveled spells depend on the class's spellbook style. available_level1 = class_spells.get(1, []) if self.class_.spellbook_style == "spellbook": # Wizard: the whole curated list forms your starting spellbook. self.spells_known = list(available_level1) elif self.class_.spellbook_style == "all_known": # Cleric: you have access to your entire class list, and choose what to prepare. self.spells_known = list(available_level1) # Auto-prepare up to your max_prepared_spells() limit so the character is playable # immediately after creation; the player can re-prepare later with 'prepare '. max_prepared = self.max_prepared_spells() self.spells_prepared = list(self.spells_known[:max_prepared]) def max_prepared_spells(self): """SRD: ability modifier + character level (minimum 1).""" if not self.spellcasting_ability: return 0 return max(1, self.mods[self.spellcasting_ability] + self.level) def spell_save_dc(self): """SRD: 8 + proficiency bonus + spellcasting ability modifier.""" if not self.spellcasting_ability: return None return 8 + self.proficiency_bonus + self.mods[self.spellcasting_ability] def spell_attack_bonus(self): """SRD: proficiency bonus + spellcasting ability modifier.""" if not self.spellcasting_ability: return None return self.proficiency_bonus + self.mods[self.spellcasting_ability] def print_spellbook(self): """Prints known cantrips, known/preparable spells, prepared spells, and slots.""" if not self.class_.is_caster: print(f"{self.class_.name}s don't cast spells.") return print(f"Spellcasting Ability: {self.spellcasting_ability}") print(f"Spell Save DC: {self.spell_save_dc()}") print(f"Spell Attack Bonus: {self.spell_attack_bonus():+}") print(f"\nCantrips Known ({len(self.cantrips_known)}):") for spell in self.cantrips_known: print(f" - {spell.name}") style_label = "Spellbook" if self.class_.spellbook_style == "spellbook" else "Class Spell List" print(f"\n{style_label} ({len(self.spells_known)}):") for spell in self.spells_known: prepared_tag = " [Prepared]" if spell in self.spells_prepared else "" print(f" - {spell.name} (Level {spell.level}){prepared_tag}") print(f"\nPrepared Spells: {len(self.spells_prepared)}/{self.max_prepared_spells()}") print("Spell Slots:") for level in sorted(self.spell_slots_max): print(f" Level {level}: {self.spell_slots_current.get(level, 0)}/{self.spell_slots_max[level]}") def prepare_spell(self, spell_name): """Moves a spell from your known list into your prepared list, if you have room.""" spell = next((s for s in self.spells_known if s.name.lower() == spell_name.lower()), None) if not spell: print(f"'{spell_name}' isn't in your {'spellbook' if self.class_.spellbook_style == 'spellbook' else 'class spell list'}.") return if spell in self.spells_prepared: print(f"{spell.name} is already prepared.") return if len(self.spells_prepared) >= self.max_prepared_spells(): print(f"You can't prepare any more spells ({self.max_prepared_spells()} max). " f"Unprepare something first.") return self.spells_prepared.append(spell) print(f"Prepared {spell.name}.") def unprepare_spell(self, spell_name): """Removes a spell from your prepared list.""" spell = next((s for s in self.spells_prepared if s.name.lower() == spell_name.lower()), None) if not spell: print(f"'{spell_name}' isn't currently prepared.") return self.spells_prepared.remove(spell) print(f"Unprepared {spell.name}.") def cast_spell(self, spell_name, slot_level=None): """ Casts a cantrip (free) or a prepared leveled spell (consumes a slot). Returns a result dict describing what happened, or None if the cast failed. """ # Cantrips are always available and never cost a slot. cantrip = next((s for s in self.cantrips_known if s.name.lower() == spell_name.lower()), None) if cantrip: return self._resolve_spell_effect(cantrip, slot_level=0) spell = next((s for s in self.spells_prepared if s.name.lower() == spell_name.lower()), None) if not spell: print(f"You don't have '{spell_name}' prepared, and it isn't a cantrip you know.") return None # Default to casting at its base level if no upcast level is specified. cast_level = slot_level or spell.level if cast_level < spell.level: print(f"{spell.name} requires at least a level {spell.level} slot.") return None if self.spell_slots_current.get(cast_level, 0) <= 0: print(f"You have no level {cast_level} spell slots remaining.") return None self.spell_slots_current[cast_level] -= 1 return self._resolve_spell_effect(spell, slot_level=cast_level) def _resolve_spell_effect(self, spell, slot_level): """Rolls damage/healing for a spell and prints/returns the outcome.""" result = {"spell": spell.name, "damage": 0, "heal": 0, "dmg_type": spell.dmg_type, "save": spell.save, "save_dc": self.spell_save_dc()} # Determine upcast bonus dice, if any. extra_levels = max(0, slot_level - spell.level) if spell.level > 0 else 0 if spell.damage: damage = roll_dice(spell.damage) if spell.scaling_damage: if "per_slot_level" in spell.scaling_damage and extra_levels: for _ in range(extra_levels): damage += roll_dice(spell.scaling_damage["per_slot_level"]) elif self.level in spell.scaling_damage: # Cantrip scaling by character level (e.g. Fire Bolt at level 5/11/17) damage = roll_dice(spell.scaling_damage.get(self.level, spell.damage)) else: for threshold in sorted([t for t in spell.scaling_damage if isinstance(t, int)], reverse=True): if self.level >= threshold: damage = roll_dice(spell.scaling_damage[threshold]) break result["damage"] = damage if spell.heal: heal = roll_dice(spell.heal) if spell.scaling_damage and "per_slot_level" in spell.scaling_damage and extra_levels: for _ in range(extra_levels): heal += roll_dice(spell.scaling_damage["per_slot_level"]) result["heal"] = heal # Print a readable summary. print(f"\nYou cast {spell.name}!") if spell.save != SaveType.NONE: print(f"Target must make a DC {result['save_dc']} {spell.save.name} saving throw.") elif spell.damage: atk_bonus = self.spell_attack_bonus() print(f"Spell attack roll: 1d20{atk_bonus:+} vs target AC.") if result["damage"]: print(f"Damage: {result['damage']} {spell.dmg_type.name if spell.dmg_type else ''}".rstrip()) if result["heal"]: print(f"Healing: {result['heal']} HP") if not spell.damage and not spell.heal: print(spell.description) return result def long_rest(self): """Restores HP to max and refills all spell slots.""" self.currentHP = self.maxHP self.spell_slots_current = dict(self.spell_slots_max) print("You take a long rest. HP and spell slots are fully restored.") def current_stats(self): """Prints a display of the user's current statistics.""" print(f'\nYour current stats are:') print(f'Hit Points: {self.currentHP}/{self.maxHP}') print(f'Strength: {self.stats["Strength"]} ({self.mods["Strength"]:+})') print(f'Dexterity: {self.stats["Dexterity"]} ({self.mods["Dexterity"]:+})') print(f'Constitution: {self.stats["Constitution"]} ({self.mods["Constitution"]:+})') print(f'Intelligence: {self.stats["Intelligence"]} ({self.mods["Intelligence"]:+})') print(f'Wisdom: {self.stats["Wisdom"]} ({self.mods["Wisdom"]:+})') print(f'Charisma: {self.stats["Charisma"]} ({self.mods["Charisma"]:+})') def drop_item(self, item_name): """Drops an item from the player's inventory.""" item_to_drop = None for item_obj in self.inventory.items: if item_obj.name.lower() == item_name.lower(): item_to_drop = item_obj break if item_to_drop: self.inventory.remove_item(item_to_drop, count=1) else: print(f"You don't have '{item_name}' in your inventory.") def new_char(self): """Outputs final stats, armour, and inventory""" self.allocation() # 1. Sets base self.stats from rolls. self.set_stats_race() # 2. Applies racial bonuses to self.stats. self.getModifier() # 3. Calculates ALL self.mods based on the FINAL self.stats (base + racial). self.set_stats_class() # 4. Calculates maxHP using the now-accurate self.mods["Constitution"]. self.current_stats() # 5. Displays the final stats. print(' ') self.inventory.current_equipment() # Corrected to call instance method print(' ') self.inventory.current_inventory() print(' ') if self.class_.is_caster: self.print_spellbook() print(' ') def health_check(self): """Print out current/max HP""" print(f'You have {self.currentHP}/{self.maxHP} HP.') def carry_report(self): """Print current weight carried vs. carrying capacity, and encumbrance status.""" self.inventory.print_carry_report(self.stats["Strength"]) def currency_report(self): """Print current coinage and its total gold-piece value.""" self.inventory.print_currency() def take_damage(self, DMGtype, size): """Define the damage type and dice size""" damage = randint(1, size) if DMGtype in self.elemental_resistance or DMGtype in self.physical_resistance: damage //= 2 self.currentHP -= damage return damage def allocation(self): """Handles the interactive allocation of rolled stats to attributes.""" # Mapping for shorthand attribute names attribute_map = { "strength": "Strength", "str": "Strength", "dexterity": "Dexterity", "dex": "Dexterity", "constitution": "Constitution", "con": "Constitution", "intelligence": "Intelligence", "int": "Intelligence", "wisdom": "Wisdom", "wis": "Wisdom", "charisma": "Charisma", "cha": "Charisma" } while True: rolls = [] stats = [] attributes = ["Strength", "Dexterity", "Constitution", "Intelligence", "Wisdom", "Charisma"] while len(stats) != 6: # Roll 4d6, drop lowest four_d_six = sorted([randint(1, 6) for _ in range(4)]) val = sum(four_d_six[1:]) # Sum the top 3 stats.append(str(val)) rolls = [] # Reset for next roll print(f"\nPlease assign a stat to a selected attribute by entering the number then the attribute.\n" f"For example: '10 strength' or '10 str' will assign 10 to your strength, if you have a 10 available.\n\n" f"Your rolled stats are:\n {', '.join(stats)}\n" f"Your attributes are:\n {', '.join(attributes)}\n") current_attributes = list(attributes) current_stats_to_assign = list(stats) # Reset player stats for new allocation attempt self.stats = {"Strength": 0, "Dexterity": 0, "Constitution": 0, "Intelligence": 0, "Wisdom": 0, "Charisma": 0} while len(current_stats_to_assign) > 0: input_text = input(">> ").lower().strip() # Convert to lower and strip whitespace words = input_text.split() if len(words) == 2: chosen_stat_str = words[0] chosen_attribute_input = words[1] # Resolve shorthand to full attribute name chosen_attribute_full = attribute_map.get(chosen_attribute_input, None) if chosen_stat_str in current_stats_to_assign and chosen_attribute_full in current_attributes: self.stats[chosen_attribute_full] = int(chosen_stat_str) # Assign, not add current_stats_to_assign.remove(chosen_stat_str) current_attributes.remove(chosen_attribute_full) if len(current_stats_to_assign) == 0: break # All stats assigned print(f"The remaining stats are:\n {', '.join(current_stats_to_assign)}\n\n" f"The remaining attributes are:\n {', '.join(current_attributes)}") else: print( "Error. Either the number or attribute was not available/recognised. Please try again with 'Number' + 'Attribute'.") else: print("Error. Input was not recognised. Please try again with 'Number' + 'Attribute'.") print(f'\nYour current stats are:\n' f'Strength: {self.stats["Strength"]}\n' f'Dexterity: {self.stats["Dexterity"]}\n' f'Constitution: {self.stats["Constitution"]}\n' f'Intelligence: {self.stats["Intelligence"]}\n' f'Wisdom: {self.stats["Wisdom"]}\n' f'Charisma: {self.stats["Charisma"]}\n') select = input("Are you happy with this selection? Y/N?\n" " WARNING: If you select 'N', your dice will be randomly rolled again. Proceed?\n" " >> ").lower() if select == "n": continue # Loop back to re-roll and re-allocate elif select == 'y': break else: print("Error. Input was not recognised. Please select 'Y' or 'N'.") # Loop back to ask again or handle as desired