diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/Kanjin-Text-RPG.iml b/.idea/Kanjin-Text-RPG.iml new file mode 100644 index 0000000..f571432 --- /dev/null +++ b/.idea/Kanjin-Text-RPG.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/dictionaries/project.xml b/.idea/dictionaries/project.xml new file mode 100644 index 0000000..c18d331 --- /dev/null +++ b/.idea/dictionaries/project.xml @@ -0,0 +1,7 @@ + + + + darkvision + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..db8786c --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..9863d7f --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..8306744 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6cfaeb9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,33 @@ +# AGENTS.md + +## Run + +```bash +python main.py +``` + +Pure stdlib. No dependencies, no build, no test/lint tooling. + +## Entrypoints + +- `main.py:main_game_loop()` — game start +- `function_list.py:startGame()` — character creation (returns `name, gender, age, race, class_, pre_allocated_stats`) + +## Play vs. debug + +To skip character creation during playtesting, replace `startGame()` with a pre-generated character and assign stats directly (see `main.py:274-290` and `function_list.py:14-94` for the 3 pre-gens). + +## Quirks + +- `sys.path.append('.')` is required at the top of `main.py`, `scene.py`, `function_list.py` — sibling imports break without it. +- `class` is a reserved keyword, so the project uses `class_` everywhere (parameter/attribute names, e.g. `Player.__init__(self, ..., class_)`). +- Race/class modules export `RACES` and `CLASSES` dicts (e.g. `RACES["Elf"]`, `CLASSES["Wizard"]`). The underlying classes (`Race`, `CharacterClass`) are preserved for instantiation if needed. +- `CharacterClass.__init__` receives `class_` as a parameter — do not confuse with Python's `class` statement. + +## Architecture + +- `scene.py` — a directed graph of `Scene` objects linked via `add_exit(direction, scene)`. The `Scene` class also holds `available_items` (free pickups) and `lootable_items` (container contents). Containers use the `Container` class which has a `description`, `lock_dc` / `trap_dc` (DC for detecting locks/traps via an Intelligence check), and visible `contents`. Locked containers can't be opened (contents hidden); trapped containers trigger if opened without detection/disarming. Use `scene.add_corpse(enemy_name, items_dict)` to drop a lootable corpse when an enemy dies. +- `player.py` — `Player`, `Inventory` (equipped slots, carried items, weight/encumbrance, currency), item hierarchy (`Item` → `Weapon`, `Armor`, `Money`). +- `spell_list.py` — `Spell` objects grouped into `SPELL_LISTS[class_name][spell_level]`. Casting uses an SRD-style slot system with upcasting. +- `enum_list.py` — shared enums (`Slots`, `DamageType`, `ItemType`, `SaveType`, etc.). +- Modifier formula: `mod = -5 + stat // 2` (standard D&D 5e). diff --git a/__pycache__/class_list.cpython-311.pyc b/__pycache__/class_list.cpython-311.pyc new file mode 100644 index 0000000..6bffa46 Binary files /dev/null and b/__pycache__/class_list.cpython-311.pyc differ diff --git a/__pycache__/class_list.cpython-312.pyc b/__pycache__/class_list.cpython-312.pyc new file mode 100644 index 0000000..f3fdd5f Binary files /dev/null and b/__pycache__/class_list.cpython-312.pyc differ diff --git a/__pycache__/enum_list.cpython-311.pyc b/__pycache__/enum_list.cpython-311.pyc new file mode 100644 index 0000000..5da0e2d Binary files /dev/null and b/__pycache__/enum_list.cpython-311.pyc differ diff --git a/__pycache__/enum_list.cpython-312.pyc b/__pycache__/enum_list.cpython-312.pyc new file mode 100644 index 0000000..eae3435 Binary files /dev/null and b/__pycache__/enum_list.cpython-312.pyc differ diff --git a/__pycache__/function_list.cpython-311.pyc b/__pycache__/function_list.cpython-311.pyc new file mode 100644 index 0000000..557e880 Binary files /dev/null and b/__pycache__/function_list.cpython-311.pyc differ diff --git a/__pycache__/function_list.cpython-312.pyc b/__pycache__/function_list.cpython-312.pyc new file mode 100644 index 0000000..501d90b Binary files /dev/null and b/__pycache__/function_list.cpython-312.pyc differ diff --git a/__pycache__/job_list.cpython-312.pyc b/__pycache__/job_list.cpython-312.pyc new file mode 100644 index 0000000..4873076 Binary files /dev/null and b/__pycache__/job_list.cpython-312.pyc differ diff --git a/__pycache__/main.cpython-311.pyc b/__pycache__/main.cpython-311.pyc new file mode 100644 index 0000000..0960fad Binary files /dev/null and b/__pycache__/main.cpython-311.pyc differ diff --git a/__pycache__/player.cpython-311.pyc b/__pycache__/player.cpython-311.pyc new file mode 100644 index 0000000..e198c4c Binary files /dev/null and b/__pycache__/player.cpython-311.pyc differ diff --git a/__pycache__/player.cpython-312.pyc b/__pycache__/player.cpython-312.pyc new file mode 100644 index 0000000..179689b Binary files /dev/null and b/__pycache__/player.cpython-312.pyc differ diff --git a/__pycache__/race_list.cpython-311.pyc b/__pycache__/race_list.cpython-311.pyc new file mode 100644 index 0000000..741923b Binary files /dev/null and b/__pycache__/race_list.cpython-311.pyc differ diff --git a/__pycache__/race_list.cpython-312.pyc b/__pycache__/race_list.cpython-312.pyc new file mode 100644 index 0000000..1c2dc36 Binary files /dev/null and b/__pycache__/race_list.cpython-312.pyc differ diff --git a/__pycache__/scene.cpython-311.pyc b/__pycache__/scene.cpython-311.pyc new file mode 100644 index 0000000..b0c8462 Binary files /dev/null and b/__pycache__/scene.cpython-311.pyc differ diff --git a/__pycache__/scene.cpython-312.pyc b/__pycache__/scene.cpython-312.pyc new file mode 100644 index 0000000..c954baf Binary files /dev/null and b/__pycache__/scene.cpython-312.pyc differ diff --git a/__pycache__/spell_list.cpython-311.pyc b/__pycache__/spell_list.cpython-311.pyc new file mode 100644 index 0000000..431979d Binary files /dev/null and b/__pycache__/spell_list.cpython-311.pyc differ diff --git a/__pycache__/spell_list.cpython-312.pyc b/__pycache__/spell_list.cpython-312.pyc new file mode 100644 index 0000000..7e6d830 Binary files /dev/null and b/__pycache__/spell_list.cpython-312.pyc differ diff --git a/job_list.py b/class_list.py similarity index 79% rename from job_list.py rename to class_list.py index 4da9939..dbd4354 100644 --- a/job_list.py +++ b/class_list.py @@ -1,5 +1,14 @@ -class Job: - def __init__(self, name, description, hitdie, proficiency, savingthrow, skill, gold): +# NOTE ON NAMING: this represents a D&D "class" (Barbarian, Cleric, Wizard...), but `class` +# is a reserved Python keyword and can't be used as a variable/parameter/attribute name. +# The convention used throughout this project (per PEP 8) is: +# - The type itself is named `CharacterClass` (capitalised type names don't collide with +# the keyword, but "Class" alone reads ambiguously next to Python's own `class` statement). +# - Any variable, parameter, or attribute that holds one of these is named `class_` +# (trailing underscore), e.g. `self.class_` on Player, or `class_=Wizard`. +class CharacterClass: + def __init__(self, name, description, hitdie, proficiency, savingthrow, skill, gold, + spellcasting_ability=None, cantrips_known=0, spell_slots=None, + spellbook_style=None): self.name = name self.description = description self.hitdie = hitdie @@ -8,6 +17,20 @@ class Job: self.skill = skill self.gold = gold + # --- Spellcasting (SRD) --- + # spellcasting_ability: e.g. "Intelligence" for Wizard, "Wisdom" for Cleric. None = non-caster. + self.spellcasting_ability = spellcasting_ability + self.cantrips_known = cantrips_known + # spell_slots: dict of {spell_level: slot_count} at character level 1. + self.spell_slots = spell_slots or {} + # spellbook_style: "spellbook" (Wizard - learns a fixed list, prepares a subset) + # "all_known" (Cleric - knows/can prepare from their entire class list) + self.spellbook_style = spellbook_style + + @property + def is_caster(self): + return self.spellcasting_ability is not None + def __repr__(self) -> str: profs = "" for key, value in self.proficiency.items(): @@ -24,7 +47,7 @@ class Job: + '\n'.join(self.skill[1:]).title() + '\n' -class Barbarian(Job): +class Barbarian(CharacterClass): def __init__(self): name = "Barbarian" description = "A tall human tribesman strides through a blizzard, draped in fur and hefting his axe. " \ @@ -72,7 +95,7 @@ class Barbarian(Job): super().__init__(name, description, hitdie, proficiency, savingthrow, skills, gold) -class Cleric(Job): +class Cleric(CharacterClass): def __init__(self): name = "Cleric" description = "Arms and eyes upraised toward the sun and a prayer on his lips, an elf begins to glow with " \ @@ -111,10 +134,12 @@ class Cleric(Job): "religion" ] gold = [2, 10] - super().__init__(name, description, hitdie, proficiency, savingthrow, skill, gold) + super().__init__(name, description, hitdie, proficiency, savingthrow, skill, gold, + spellcasting_ability="Wisdom", cantrips_known=3, + spell_slots={1: 2}, spellbook_style="all_known") -class Wizard(Job): +class Wizard(CharacterClass): def __init__(self): name = "Wizard" description = "Clad in the silver robes that denote her station, an elf closes her eyes to shut out the " \ @@ -165,9 +190,13 @@ class Wizard(Job): "religion" ] gold = [2, 10] - super().__init__(name, description, hitdie, proficiency, savingthrow, skills, gold) + super().__init__(name, description, hitdie, proficiency, savingthrow, skills, gold, + spellcasting_ability="Intelligence", cantrips_known=3, + spell_slots={1: 2}, spellbook_style="spellbook") -Barbarian = Barbarian() -Cleric = Cleric() -Wizard = Wizard() +CLASSES = { + "Barbarian": Barbarian(), + "Cleric": Cleric(), + "Wizard": Wizard(), +} diff --git a/enum_list.py b/enum_list.py index b869353..ad4e1d3 100644 --- a/enum_list.py +++ b/enum_list.py @@ -47,4 +47,26 @@ class Slots(Enum): LeftRing = auto() RightRing = auto() Other = auto() - Attunement = auto() \ No newline at end of file + Attunement = auto() + + +class SpellSchool(Enum): + Abjuration = auto() + Conjuration = auto() + Divination = auto() + Enchantment = auto() + Evocation = auto() + Illusion = auto() + Necromancy = auto() + Transmutation = auto() + + +class SaveType(Enum): + """Which stat a target rolls to resist a spell's effect.""" + Strength = auto() + Dexterity = auto() + Constitution = auto() + Intelligence = auto() + Wisdom = auto() + Charisma = auto() + NONE = auto() # No saving throw (e.g. Magic Missile) \ No newline at end of file diff --git a/function_list.py b/function_list.py index acc5290..d9c2af0 100644 --- a/function_list.py +++ b/function_list.py @@ -4,8 +4,8 @@ sys.path.append('.') from player import Player # Only import what's necessary from enum_list import ItemType, Slots -from race_list import Elf, Dwarf, Human -from job_list import Barbarian, Cleric, Wizard +from race_list import RACES +from class_list import CLASSES def wait(): @@ -16,8 +16,8 @@ pre_generated_characters = { "name": "Arion", # Example Name "gender": "Male", "age": 25, - "race": Elf, - "job": Wizard, # Based on the High Elf sheet + "race": RACES["Elf"], + "class_": CLASSES["Wizard"], # Based on the High Elf sheet "alignment": "lawful good", "description": "A scholarly High Elf skilled in the arcane arts.", "stats": { @@ -41,8 +41,8 @@ pre_generated_characters = { "name": "Brundle", # Example Name "gender": "Female", "age": 35, - "race": Human, - "job": Barbarian, # Based on the Human sheet + "race": RACES["Human"], + "class_": CLASSES["Barbarian"], # Based on the Human sheet "alignment": "chaotic good", "description": "A robust Human warrior, charging into battle.", "stats": { @@ -67,8 +67,8 @@ pre_generated_characters = { "name": "Drok", # Example Name "gender": "Other", "age": 75, # Dwarves live longer - "race": Dwarf, - "job": Cleric, # Based on the Hill Dwarf sheet (Life Domain implied by "healing" focus) + "race": RACES["Dwarf"], + "class_": CLASSES["Cleric"], # Based on the Hill Dwarf sheet (Life Domain implied by "healing" focus) "alignment": "lawful good", "description": "A stout Hill Dwarf cleric, a pillar of his community.", "stats": { @@ -142,10 +142,10 @@ def startGame(): print("Sorry, I didn't catch that. Please try again.\n") continue - # --- Race and Job Selection --- + # --- Race and Class Selection --- race = None - job = None - # Loop until Race and Job are confirmed + class_ = None + # Loop until Race and Class are confirmed while True: # Race Selection selected_race_obj = None @@ -154,11 +154,11 @@ def startGame(): race_choice = input("Please select a race to learn more about it:\n" "1) Elf\n2) Dwarf\n3) Human\n >> ").title() if race_choice in ['Elf', '1']: - selected_race_obj = Elf + selected_race_obj = RACES["Elf"] elif race_choice in ['Dwarf', '2']: - selected_race_obj = Dwarf + selected_race_obj = RACES["Dwarf"] elif race_choice in ['Human', '3']: - selected_race_obj = Human + selected_race_obj = RACES["Human"] else: print("Sorry I didn't recognise that race. Please select 'Elf', 'Dwarf', or 'Human'.\n") continue @@ -177,48 +177,48 @@ def startGame(): print("Sorry, I didn't catch that. Please try again.\n") continue - # Job Selection - selected_job_obj = None - # Loop for selecting and viewing job + # Class Selection + selected_class_obj = None + # Loop for selecting and viewing class while True: - job_choice = input("Please select a job to learn more about it:\n" + class_choice = input("Please select a class to learn more about it:\n" "1) Barbarian\n2) Cleric\n3) Wizard\n >> ").title() - if job_choice in ['Barbarian', '1']: - selected_job_obj = Barbarian - elif job_choice in ['Cleric', '2']: - selected_job_obj = Cleric - elif job_choice in ['Wizard', '3']: - selected_job_obj = Wizard + if class_choice in ['Barbarian', '1']: + selected_class_obj = CLASSES["Barbarian"] + elif class_choice in ['Cleric', '2']: + selected_class_obj = CLASSES["Cleric"] + elif class_choice in ['Wizard', '3']: + selected_class_obj = CLASSES["Wizard"] else: - print("Sorry I didn't recognise that job. Please select 'Barbarian', 'Cleric', or 'Wizard'.\n") + print("Sorry I didn't recognise that class. Please select 'Barbarian', 'Cleric', or 'Wizard'.\n") continue - if selected_job_obj: - print(f"\n--- {selected_job_obj.name} ---") - print(selected_job_obj.__repr__()) + if selected_class_obj: + print(f"\n--- {selected_class_obj.name} ---") + print(selected_class_obj.__repr__()) - print('Would you like to proceed with this job or view another?') - proceed = input('1) Proceed\n2) View another job\n >> ') + print('Would you like to proceed with this class or view another?') + proceed = input('1) Proceed\n2) View another class\n >> ') if proceed.lower() in ('1', 'proceed'): - job = selected_job_obj # Assign job object - break # Exit job selection loop - elif proceed.lower() in ('2', 'view', 'view another', 'view another job'): - continue # Restart job selection + class_ = selected_class_obj # Assign class object + break # Exit class selection loop + elif proceed.lower() in ('2', 'view', 'view another', 'view another class'): + continue # Restart class selection else: print("Sorry, I didn't catch that. Please try again.\n") continue - # Final confirmation for both Race and Job + # Final confirmation for both Race and Class while True: # This safeguard should ideally not be hit if inner loops work correctly - if race is None or job is None: - print("Error: Race or Job not selected. Restarting Race/Job selection.") - break # Break out of this inner loop to restart the outer race/job loop - correct = input(f"\n{name}, you are a {race.name_adjective} {job.name}.\nIs this correct? Y/N\n >> ") + if race is None or class_ is None: + print("Error: Race or Class not selected. Restarting Race/Class selection.") + break # Break out of this inner loop to restart the outer race/class loop + correct = input(f"\n{name}, you are a {race.name_adjective} {class_.name}.\nIs this correct? Y/N\n >> ") if correct.lower() in ['y', 'yes']: - return name, gender, age, race, job, None# All confirmed, return values + return name, gender, age, race, class_, None# All confirmed, return values elif correct.lower() in ['n', 'no']: - # If not correct, break this loop to re-enter race/job selection + # If not correct, break this loop to re-enter race/class selection break else: print("Sorry, I didn't catch that. Please try again.\n") @@ -233,19 +233,19 @@ def startGame(): gender = input("Gender (Male, Female, Other): ") age = int(input("Age: ")) race_str = input("Race (Elf, Dwarf, Human): ") - job_str = input("Job (Barbarian, Cleric, Wizard): ") + class_str = input("Class (Barbarian, Cleric, Wizard): ") - # Map string inputs to actual Race/Job objects for consistency - race_map = {'elf': Elf, 'dwarf': Dwarf, 'human': Human} - job_map = {'barbarian': Barbarian, 'cleric': Cleric, 'wizard': Wizard} + # Map string inputs to actual Race/Class objects for consistency + race_map = {'elf': RACES["Elf"], 'dwarf': RACES["Dwarf"], 'human': RACES["Human"]} + class_map = {'barbarian': CLASSES["Barbarian"], 'cleric': CLASSES["Cleric"], 'wizard': CLASSES["Wizard"]} actual_race = race_map.get(race_str.lower()) - actual_job = job_map.get(job_str.lower()) + actual_class = class_map.get(class_str.lower()) - if actual_race and actual_job: - return name, gender, age, actual_race, actual_job, None + if actual_race and actual_class: + return name, gender, age, actual_race, actual_class, None else: - print("Invalid race or job entered for pre-generated character. Please try again.") + print("Invalid race or class entered for pre-generated character. Please try again.") start = "1" continue return None @@ -267,7 +267,7 @@ def startGame(): print("Who would you like to to play?") for key, char_data in pre_generated_characters.items(): print( - f"{key}) {char_data['name']} ({char_data['race'].name_adjective} {char_data['job'].name}, {char_data['alignment']})") + f"{key}) {char_data['name']} ({char_data['race'].name_adjective} {char_data['class_'].name}, {char_data['alignment']})") print("Type 'back' to return to character creation options.") char_choice = input(">> ").strip().lower() @@ -284,7 +284,7 @@ def startGame(): print(f"Gender: {selected_char_data['gender']}") print(f"Age: {selected_char_data['age']}") print(f"Race: {selected_char_data['race'].name_adjective}") - print(f"Class: {selected_char_data['job'].name}") + print(f"Class: {selected_char_data['class_'].name}") print(f"Alignment: {selected_char_data['alignment']}") print(f"Description: {selected_char_data['description']}") print(f"Age: {selected_char_data['age']}") @@ -340,11 +340,11 @@ def startGame(): gender = selected_char_data['gender'] age = selected_char_data['age'] race = selected_char_data['race'] - job = selected_char_data['job'] + class_ = selected_char_data['class_'] pre_allocated_stats = selected_char_data['stats'] - print(f"\nYou have selected: {name}, a {age} year old {race.name_adjective} {job.name}.") - return name, gender, age, race, job, pre_allocated_stats # Exit all loops and function + print(f"\nYou have selected: {name}, a {age} year old {race.name_adjective} {class_.name}.") + return name, gender, age, race, class_, pre_allocated_stats # Exit all loops and function elif confirm_choice in ("2", "no"): print("\nReturning to pre-generated character selection.") @@ -532,7 +532,13 @@ def get_instructions(): "Enter cave | house | room - maybe... TBC\n" "Scene or Location - Replays the current area's details\n" "Look around - lists the available items in your scene without displaying the scene description.\n" - "Examine | Open | Loot *object* - Provides details of an item, opens a container, or takes an object.") + "Examine | Open | Loot *object* - Provides details of an item, opens a container, or takes an object.\n" + "Spells | Spellbook - Lists your cantrips, known/preparable spells, prepared spells, and slots.\n" + "Cast *spell name* - Casts a cantrip or prepared spell.\n" + "Prepare | Unprepare *spell name* - Moves a spell in/out of your prepared list.\n" + "Rest - Long rest: restores HP and spell slots.\n" + "Check weight | carry | encumbrance - Shows carrying weight vs capacity.\n" + "Check currency | coins | gold - Shows coinage held and total gp value.") print(descrip1) time.sleep(3) # Reduced sleep for faster testing print(descrip2) @@ -580,6 +586,24 @@ def parse(input_text): command = "go" object1 = " ".join(words[1:]) return command, object1 + elif words[0] == "cast": + command = "cast" + object1 = " ".join(words[1:]) + return command, object1 + elif words[0] == "prepare": + command = "prepare" + object1 = " ".join(words[1:]) + return command, object1 + elif words[0] == "unprepare": + command = "unprepare" + object1 = " ".join(words[1:]) + return command, object1 + elif words[0] == "check" and words[1] in ("weight", "carry", "encumbrance"): + command = "weight" + return command, object1 + elif words[0] == "check" and words[1] in ("currency", "coins", "gold", "money"): + command = "currency" + return command, object1 # Single-word commands if words[0] == "help": @@ -630,6 +654,14 @@ def parse(input_text): command = "drop" # User needs to specify what to drop object1 = None + elif words[0] in ("spells", "spellbook"): + command = "spells" + elif words[0] == "rest": + command = "rest" + elif words[0] in ("weight", "carry", "encumbrance"): + command = "weight" + elif words[0] in ("currency", "coins", "gold", "money"): + command = "currency" elif words[0] == "quit": command = "quit" else: diff --git a/main.py b/main.py index 1edbd52..09a3ffb 100644 --- a/main.py +++ b/main.py @@ -1,4 +1,5 @@ import sys +from random import randint # Add the current directory to sys.path to allow imports from sibling files sys.path.append('.') from player import Player @@ -36,8 +37,8 @@ class Engine: # Display lootable containers if self.current_scene.lootable_items: print("\nYou also notice:") - for container_name, items_in_container in self.current_scene.lootable_items.items(): - if any(count > 0 for count in items_in_container.values()): # Check if container has any items left + for container_name, container in self.current_scene.lootable_items.items(): + if container.has_items(): print(f" - {container_name.title()}") def look_around(self): @@ -53,8 +54,8 @@ class Engine: # Display lootable containers if self.current_scene.lootable_items: print("\nYou also notice:") - for container_name, items_in_container in self.current_scene.lootable_items.items(): - if any(count > 0 for count in items_in_container.values()): # Check if container has any items left + for container_name, container in self.current_scene.lootable_items.items(): + if container.has_items(): print(f" - {container_name.title()}") @@ -116,19 +117,45 @@ class Engine: if not found_in_scene: # Check lootable containers in the current scene found_in_loot = False - for container_name, items_in_container in self.current_scene.lootable_items.items(): - if object_name.lower() in container_name.lower(): # If user tries to examine the container itself - print(f"\nYou examine the {container_name}. Inside, you see:") - if items_in_container: - for item_obj, count in items_in_container.items(): - if count > 0: - print(f" - {item_obj.name} (x{count})") - else: - print("It appears to be empty.") - found_in_loot = True - break # Exit after examining the container + for container_name, container in self.current_scene.lootable_items.items(): + if object_name.lower() in container_name.lower(): + print(f"\n--- {container.name.title()} ---") + print(container.description) - for item_obj, count in items_in_container.items(): + int_mod = self.player.mods["Intelligence"] + noticed_something = False + + if container.lock_dc: + roll = randint(1, 20) + int_mod + print(f"\nInvestigation check: 1d20{int_mod:+} = {roll} vs DC {container.lock_dc}") + if roll >= container.lock_dc: + print("It appears to be locked.") + noticed_something = True + elif not container.trap_dc: + print("You don't notice anything unusual about it.") + + if container.trap_dc: + roll = randint(1, 20) + int_mod + print(f"\nInvestigation check: 1d20{int_mod:+} = {roll} vs DC {container.trap_dc}") + if roll >= container.trap_dc: + print("You notice a trap mechanism on it.") + container.trap_detected = True + noticed_something = True + elif not container.lock_dc and not noticed_something: + print("You don't notice anything unusual about it.") + + if not container.is_locked: + if container.contents: + print("\nInside, you see:") + for item_obj, count in container.contents.items(): + if count > 0: + print(f" - {item_obj.name} (x{count})") + else: + print("It appears to be empty.") + found_in_loot = True + break + + for item_obj, count in container.contents.items(): if object_name.lower() in item_obj.name.lower() and count > 0: print(f"\n--- {item_obj.name} ---") print(item_obj.description) @@ -185,22 +212,32 @@ class Engine: def loot_container(self, container_name): """Allows the player to loot items from a specified container.""" - container_found = False - for current_container_name, items_in_container in list(self.current_scene.lootable_items.items()): - if current_container_name.lower() == container_name.lower(): - container_found = True - if not items_in_container: + for current_name, container in list(self.current_scene.lootable_items.items()): + if current_name.lower() == container_name.lower(): + if container.is_locked: + print(f"The {container_name} is locked.") + return + + if container.is_trapped and not container.trap_disarmed: + if container.trap_detected: + print(f"The {container_name} is trapped. You'll need to disable the trap first.") + return + else: + print(f"You open the {container_name}. A trap is triggered!") + # TODO: trap damage/effects + return + + if not container.contents: print(f"The {container_name} is empty.") return print(f"You look inside the {container_name}. You see:") - loot_list = list(items_in_container.items()) + loot_list = list(container.contents.items()) while True: for i, (item_obj, count) in enumerate(loot_list, 1): print(f"{i}) {item_obj.name} (x{count})") - take_all_option = len(loot_list) + 1 leave_option = len(loot_list) + 2 @@ -209,13 +246,14 @@ class Engine: choice = input("What would you like to take? (number or 'take all'/'leave')\n>> ").lower().strip() - if choice == "take all" or (choice.isdigit() and int(choice) == take_all_option): - for item_obj, count in list(items_in_container.items()): + for item_obj, count in list(container.contents.items()): self.player.inventory.add_item(item_obj, count) print(f"- Took {count} {item_obj.name}") - del items_in_container[item_obj] + container.remove_item(item_obj, count) print(f"The {container_name} is now empty.") + if not container.has_items(): + del self.current_scene.lootable_items[current_name] return elif choice == "leave" or (choice.isdigit() and int(choice) == leave_option): print(f"You leave the {container_name} untouched.") @@ -239,18 +277,16 @@ class Engine: if 0 < take_count <= current_count: self.player.inventory.add_item(item_obj, take_count) - items_in_container[item_obj] -= take_count + container.remove_item(item_obj, take_count) print(f"You took {take_count} {item_obj.name}.") - if items_in_container[item_obj] <= 0: - del items_in_container[item_obj] - loot_list = list(items_in_container.items()) + if item_obj not in container.contents: + loot_list = list(container.contents.items()) - if not items_in_container: - print(f"The {container_name} is now empty.") - return - elif not loot_list: + if not container.contents: print(f"The {container_name} is now empty.") + if not container.has_items(): + del self.current_scene.lootable_items[current_name] return else: print("Invalid amount or not enough items.") @@ -260,8 +296,7 @@ class Engine: print("Invalid selection. Please enter a number, 'take all', or 'leave'.") return - if not container_found: - print(f"You don't see a '{container_name}' here to loot.") + print(f"You don't see a '{container_name}' here to loot.") # Initialize the game engine @@ -272,10 +307,10 @@ def main_game_loop(): The main loop for the game, handling character creation and continuous gameplay. """ # Character Creation - name, gender, age, race, job, pre_allocated_stats = startGame() + name, gender, age, race, class_, pre_allocated_stats = startGame() # Create player instance - player = Player(name, gender, age, race, job) + player = Player(name, gender, age, race, class_) game_engine.set_player(player) # Set the player in the game engine # Display initial character summary @@ -286,13 +321,16 @@ def main_game_loop(): # For 'Pre-generated Character' game_engine.player.stats = pre_allocated_stats game_engine.player.getModifier() - game_engine.player.set_stats_job() # Still need to set HP based on job and Con modifier + game_engine.player.set_stats_class() # Still need to set HP based on class and Con modifier game_engine.player.current_stats() # Display stats after setting them print(' ') game_engine.player.inventory.current_equipment() print(' ') game_engine.player.inventory.current_inventory() print(' ') + if game_engine.player.class_.is_caster: + game_engine.player.print_spellbook() + print(' ') wait() print("\nYour adventure begins...") @@ -340,6 +378,29 @@ def main_game_loop(): elif command == "go": direction = obj1 game_engine.move_to_scene(direction) + elif command == "spells": + game_engine.player.print_spellbook() + elif command == "cast": + if obj1: + game_engine.player.cast_spell(obj1) + else: + print("What would you like to cast?") + elif command == "prepare": + if obj1: + game_engine.player.prepare_spell(obj1) + else: + print("What would you like to prepare?") + elif command == "unprepare": + if obj1: + game_engine.player.unprepare_spell(obj1) + else: + print("What would you like to unprepare?") + elif command == "rest": + game_engine.player.long_rest() + elif command == "weight": + game_engine.player.carry_report() + elif command == "currency": + game_engine.player.currency_report() elif command == "quit": print("Thanks for playing!") break diff --git a/player.py b/player.py index 2b77059..a5c2039 100644 --- a/player.py +++ b/player.py @@ -1,16 +1,33 @@ +import re from random import randint -from enum_list import DamageType, DamageMod, ItemType, Slots +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): + 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" @@ -29,13 +46,14 @@ class Money(Item): value=self.amt, magical=self.magical, itemType=ItemType.Money, - attunement=False) + 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): + dmgType, dmgMod, versatile, thrown, ItemType, magical, attunement, weight=0): self.slot = slot self.damage1H = damage1H self.damage2H = damage2H @@ -47,7 +65,7 @@ class Weapon(Item): self.versatile = versatile self.thrown = thrown self.itemType = ItemType - super().__init__(name, description, value, magical, ItemType, attunement) + super().__init__(name, description, value, magical, ItemType, attunement, weight) def __str__(self): if self.damage2H is None and self.damage1H is not None: @@ -84,14 +102,15 @@ class Weapon(Item): class Armor(Item): """The base class for all armor""" - def __init__(self, name, description, value, slot, grade, ac, disadvantage, dmgRes, ItemType, magical, attunement): + 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) + super().__init__(name, description, value, magical, ItemType, attunement, weight) def __repr__(self): if self.stealthDis: @@ -299,6 +318,65 @@ class Inventory: 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 @@ -323,7 +401,8 @@ rock = Weapon( thrown=True, magical=False, ItemType=ItemType.Weapon, - attunement=False + attunement=False, + weight=2 ) dagger = Weapon( @@ -342,7 +421,8 @@ dagger = Weapon( thrown=True, magical=False, ItemType=ItemType.Weapon, - attunement=False + attunement=False, + weight=1 ) polearm = Weapon( @@ -361,7 +441,8 @@ polearm = Weapon( thrown=False, # Polearms are not typically thrown magical=False, ItemType=ItemType.Weapon, - attunement=False + attunement=False, + weight=6 ) tornRags = Armor( @@ -375,7 +456,8 @@ tornRags = Armor( dmgRes="None", magical=False, ItemType=ItemType.Armor, - attunement=False + attunement=False, + weight=2 ) paper = Item( @@ -384,19 +466,22 @@ paper = Item( value=None, magical=False, itemType=ItemType.Item, - attunement=False + attunement=False, + weight=0 ) class Player: """Character Creation""" - def __init__(self, name, gender, age, race, job): + def __init__(self, name, gender, age, race, class_): # Identity self.name = name self.gender = gender self.age = age self.race = race - self.job = job + # `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 @@ -423,6 +508,19 @@ class Player: # 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, @@ -540,22 +638,198 @@ class Player: print("As a Dwarf, your constitution increases by 2.") self.stats["Constitution"] += 2 - def set_stats_job(self): - """Applies job-specific stats like hit points.""" - # Using self.job.name to access the job name from the Job object - if self.job.name.lower() == "barbarian": - self.hitDie = randint(self.job.hitdie[0], self.job.hitdie[1]) # Use job's hitdie range - self.maxHP = (self.job.hitdie[1] + self.mods["Constitution"]) # Use max hitdie value for initial HP + 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.job.name.lower() == "cleric": - self.hitDie = randint(self.job.hitdie[0], self.job.hitdie[1]) - self.maxHP = (self.job.hitdie[1] + self.mods["Constitution"]) + 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.job.name.lower() == "wizard": - self.hitDie = randint(self.job.hitdie[0], self.job.hitdie[1]) - self.maxHP = (self.job.hitdie[1] + self.mods["Constitution"]) + 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:') @@ -584,18 +858,29 @@ class Player: 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_job() # 4. Calculates maxHP using the now-accurate self.mods["Constitution"]. + 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) diff --git a/race_list.py b/race_list.py index 5807ffa..7257f3c 100644 --- a/race_list.py +++ b/race_list.py @@ -39,7 +39,7 @@ class Elf(Race): speed = { 'Walking': 30 } - darkvision = [30, 60] + darkvision = 60 language = ["Common", "Elvish"] advantage = [] resistance = [] @@ -155,6 +155,8 @@ class Human(Race): language, advantage, resistance, proficiency, traits) -Elf = Elf() -Dwarf = Dwarf() -Human = Human() +RACES = { + "Elf": Elf(), + "Dwarf": Dwarf(), + "Human": Human(), +} diff --git a/scene.py b/scene.py index 26afcdd..748cb76 100644 --- a/scene.py +++ b/scene.py @@ -5,6 +5,36 @@ sys.path.append('.') from player import Item, Weapon, Armor, rock, dagger, polearm, tornRags, paper, GP1, SP1, CP1 from enum_list import * +class Container: + def __init__(self, name, description, lock_dc=None, trap_dc=None): + self.name = name + self.description = description + self.lock_dc = lock_dc + self.trap_dc = trap_dc + self.trap_disarmed = False + self.contents = {} + + @property + def is_locked(self): + return self.lock_dc is not None + + @property + def is_trapped(self): + return self.trap_dc is not None + + def add_item(self, item_obj, count=1): + self.contents[item_obj] = self.contents.get(item_obj, 0) + count + + def remove_item(self, item_obj, count=1): + if item_obj in self.contents: + self.contents[item_obj] -= count + if self.contents[item_obj] <= 0: + del self.contents[item_obj] + + def has_items(self): + return bool(self.contents) + + class Scene: """ Represents a single location or area in the game. @@ -45,15 +75,12 @@ class Scene: return self.exits.get(direction.lower()) def add_lootable_item(self, container_name, item_obj, count=1): - """ - Adds an item to a lootable container within the scene. - :param container_name: The name of the container (e.g., "chest", "goblin corpse"). - :param item_obj: The Item object to add. - :param count: The quantity of the item. - """ if container_name not in self.lootable_items: - self.lootable_items[container_name] = {} - self.lootable_items[container_name][item_obj] = self.lootable_items[container_name].get(item_obj, 0) + count + self.lootable_items[container_name] = Container(container_name, "") + self.lootable_items[container_name].add_item(item_obj, count) + + def add_container(self, container): + self.lootable_items[container.name] = container def add_available_item(self, item_obj, count=1): """ @@ -75,18 +102,31 @@ class Scene: del self.available_items[item_obj] def remove_lootable_item(self, container_name, item_obj, count=1): + if container_name in self.lootable_items: + self.lootable_items[container_name].remove_item(item_obj, count) + if not self.lootable_items[container_name].has_items(): + del self.lootable_items[container_name] + + def add_corpse(self, enemy_name, items): + """Creates a lootable corpse from a defeated enemy. + Multiple corpses of the same enemy are numbered: 'spider corpse', 'spider corpse 2', etc. + items: dict of {item_obj: count} or iterable of (item_obj, count) pairs. """ - Removes a specified count of an item from a lootable container in the scene. - :param container_name: The name of the container. - :param item_obj: The Item object to remove. - :param count: The quantity to remove. - """ - if container_name in self.lootable_items and item_obj in self.lootable_items[container_name]: - self.lootable_items[container_name][item_obj] -= count - if self.lootable_items[container_name][item_obj] <= 0: - del self.lootable_items[container_name][item_obj] - if not self.lootable_items[container_name]: # If container is empty, remove it - del self.lootable_items[container_name] + base_name = f"{enemy_name.lower()} corpse" + name = base_name + counter = 1 + while name in self.lootable_items: + counter += 1 + name = f"{base_name} {counter}" + + corpse = Container(name, f"The lifeless body of a {enemy_name}.") + if isinstance(items, dict): + for item_obj, count in items.items(): + corpse.add_item(item_obj, count) + else: + for item_obj, count in items: + corpse.add_item(item_obj, count) + self.lootable_items[name] = corpse # --- Default Scenes --- @@ -112,9 +152,9 @@ tutorial = Scene( ) tutorial.add_available_item(rock, 1) tutorial.add_available_item(paper, 2) -tutorial.add_lootable_item("suspicious tree trunk", Item("Test Item", - "This is for testing purposes", None, - False, ItemType.Item, False), 1) +tutorial_trunk = Container("suspicious tree trunk", "A hollow tree trunk with a small gap in the bark.") +tutorial_trunk.add_item(Item("Test Item", "This is for testing purposes", None, False, ItemType.Item, False), 1) +tutorial.add_container(tutorial_trunk) # Scene 1: Starting Clearing starting_clearing = Scene( @@ -125,8 +165,10 @@ starting_clearing = Scene( "You notice a small, weathered wooden chest near a fallen log." ) starting_clearing.add_available_item(rock, 3) # Rocks lying on the ground -starting_clearing.add_lootable_item("small wooden chest", GP1, 15) -starting_clearing.add_lootable_item("small wooden chest", SP1, 20) +starting_clearing_chest = Container("small wooden chest", "A small, weathered wooden chest with rusty iron bands.") +starting_clearing_chest.add_item(GP1, 15) +starting_clearing_chest.add_item(SP1, 20) +starting_clearing.add_container(starting_clearing_chest) # Scene 2: Forest Path diff --git a/spell_list.py b/spell_list.py new file mode 100644 index 0000000..b05e974 --- /dev/null +++ b/spell_list.py @@ -0,0 +1,221 @@ +from enum_list import SpellSchool, SaveType, DamageType + + +class Spell: + """Base class for all spells, built from the D&D 5e SRD.""" + def __init__(self, name, level, school, casting_time, range_, components, + duration, description, classes, damage=None, dmg_type=None, + scaling_damage=None, save=SaveType.NONE, heal=None, + ritual=False, concentration=False): + self.name = name + self.level = level # 0 = cantrip + self.school = school + self.casting_time = casting_time + self.range = range_ + self.components = components + self.duration = duration + self.description = description + self.classes = classes # list of class names that can cast this, e.g. ["Wizard"] + self.damage = damage # base damage dice string e.g. "1d10" + self.dmg_type = dmg_type + self.scaling_damage = scaling_damage # dice added per extra slot level/character level, if any + self.save = save + self.heal = heal # healing dice string, e.g. "1d8" + self.ritual = ritual + self.concentration = concentration + + def __repr__(self): + level_str = "Cantrip" if self.level == 0 else f"Level {self.level}" + tags = [] + if self.ritual: + tags.append("Ritual") + if self.concentration: + tags.append("Concentration") + tag_str = f" ({', '.join(tags)})" if tags else "" + return (f"{self.name} - {level_str} {self.school.name}{tag_str}\n" + f"=====\n" + f"Casting Time: {self.casting_time}\n" + f"Range: {self.range}\n" + f"Components: {self.components}\n" + f"Duration: {self.duration}\n\n" + f"{self.description}") + + +# ----- Wizard Cantrips ----- +fire_bolt = Spell( + name="Fire Bolt", level=0, school=SpellSchool.Evocation, + casting_time="1 action", range_="120 feet", components="V, S", + duration="Instantaneous", + description="You hurl a mote of fire at a creature or object within range. Make a ranged " + "spell attack against the target. On a hit, the target takes fire damage. " + "A flammable object hit by this spell ignites if it isn't being worn or carried.", + classes=["Wizard"], damage="1d10", dmg_type=DamageType.Fire, + scaling_damage={5: "2d10", 11: "3d10", 17: "4d10"} +) + +ray_of_frost = Spell( + name="Ray of Frost", level=0, school=SpellSchool.Evocation, + casting_time="1 action", range_="60 feet", components="V, S", + duration="Instantaneous", + description="A frigid beam of blue-white light streaks toward a creature within range. " + "Make a ranged spell attack against the target. On a hit, it takes cold " + "damage and its speed is reduced by 10 feet until the start of your next turn.", + classes=["Wizard"], damage="1d8", dmg_type=DamageType.Cold, + scaling_damage={5: "2d8", 11: "3d8", 17: "4d8"} +) + +mage_hand = Spell( + name="Mage Hand", level=0, school=SpellSchool.Conjuration, + casting_time="1 action", range_="30 feet", components="V, S", + duration="1 minute", + description="A spectral, floating hand appears at a point you choose within range. " + "The hand lasts for the duration and can manipulate objects, open unlocked " + "containers, and carry up to 10 pounds.", + classes=["Wizard"] +) + +prestidigitation = Spell( + name="Prestidigitation", level=0, school=SpellSchool.Transmutation, + casting_time="1 action", range_="10 feet", components="V, S", + duration="Up to 1 hour", + description="This spell is a minor magical trick: create a harmless sensory effect, " + "light or snuff a small flame, clean or soil an object, chill/warm/flavor " + "a small amount of nonliving material, or make a small mark or symbol appear.", + classes=["Wizard"] +) + +# ----- Wizard Level 1 Spells ----- +magic_missile = Spell( + name="Magic Missile", level=1, school=SpellSchool.Evocation, + casting_time="1 action", range_="120 feet", components="V, S", + duration="Instantaneous", + description="You create three glowing darts of magical force. Each dart hits a creature " + "of your choice that you can see within range, dealing force damage. The " + "darts all strike simultaneously and automatically hit - no attack roll needed.", + classes=["Wizard"], damage="3d4+3", dmg_type=DamageType.Force, + scaling_damage={"per_slot_level": "1d4+1"} +) + +shield = Spell( + name="Shield", level=1, school=SpellSchool.Abjuration, + casting_time="1 reaction", range_="Self", components="V, S", + duration="1 round", + description="An invisible barrier of magical force appears and protects you. Until the " + "start of your next turn, you have a +5 bonus to AC and you take no damage " + "from Magic Missile.", + classes=["Wizard"] +) + +burning_hands = Spell( + name="Burning Hands", level=1, school=SpellSchool.Evocation, + casting_time="1 action", range_="Self (15-foot cone)", components="V, S", + duration="Instantaneous", + description="A thin sheet of flames shoots forth from your outstretched fingertips. Each " + "creature in a 15-foot cone must make a Dexterity saving throw. A creature " + "takes fire damage on a failed save, or half as much on a successful one.", + classes=["Wizard"], damage="3d6", dmg_type=DamageType.Fire, save=SaveType.Dexterity, + scaling_damage={"per_slot_level": "1d6"} +) + +detect_magic = Spell( + name="Detect Magic", level=1, school=SpellSchool.Divination, + casting_time="1 action", range_="Self", components="V, S", + duration="Concentration, up to 10 minutes", + description="For the duration, you sense the presence of magic within 30 feet of you. " + "If you sense magic in this way, you can use your action to see a faint " + "aura around any visible creature or object that bears magic.", + classes=["Wizard", "Cleric"], ritual=True, concentration=True +) + +# ----- Cleric Cantrips ----- +sacred_flame = Spell( + name="Sacred Flame", level=0, school=SpellSchool.Evocation, + casting_time="1 action", range_="60 feet", components="V, S", + duration="Instantaneous", + description="Flame-like radiance descends on a creature that you can see within range. " + "The target must succeed on a Dexterity saving throw or take radiant damage. " + "The target gains no benefit from cover for this saving throw.", + classes=["Cleric"], damage="1d8", dmg_type=DamageType.Radiant, save=SaveType.Dexterity, + scaling_damage={5: "2d8", 11: "3d8", 17: "4d8"} +) + +guidance = Spell( + name="Guidance", level=0, school=SpellSchool.Divination, + casting_time="1 action", range_="Touch", components="V, S", + duration="Concentration, up to 1 minute", + description="You touch one willing creature. Once before the spell ends, the target " + "can roll a d4 and add the number rolled to one ability check of its choice.", + classes=["Cleric"], concentration=True +) + +spare_the_dying = Spell( + name="Spare the Dying", level=0, school=SpellSchool.Necromancy, + casting_time="1 action", range_="Touch", components="V, S", + duration="Instantaneous", + description="You touch a living creature that has 0 hit points. The creature becomes " + "stable. This spell has no effect on undead or constructs.", + classes=["Cleric"] +) + +# ----- Cleric Level 1 Spells ----- +cure_wounds = Spell( + name="Cure Wounds", level=1, school=SpellSchool.Evocation, + casting_time="1 action", range_="Touch", components="V, S", + duration="Instantaneous", + description="A creature you touch regains a number of hit points.", + classes=["Cleric"], heal="1d8", scaling_damage={"per_slot_level": "1d8"} +) + +bless = Spell( + name="Bless", level=1, school=SpellSchool.Enchantment, + casting_time="1 action", range_="30 feet", components="V, S, M", + duration="Concentration, up to 1 minute", + description="You bless up to three creatures of your choice within range. Whenever a " + "target makes an attack roll or a saving throw before the spell ends, the " + "target can add 1d4 to the attack roll or saving throw.", + classes=["Cleric"], concentration=True +) + +guiding_bolt = Spell( + name="Guiding Bolt", level=1, school=SpellSchool.Evocation, + casting_time="1 action", range_="120 feet", components="V, S", + duration="1 round", + description="A flash of light streaks toward a creature of your choice within range. " + "Make a ranged spell attack against the target. On a hit, the target takes " + "radiant damage, and the next attack roll made against this target before " + "the end of your next turn has advantage.", + classes=["Cleric"], damage="4d6", dmg_type=DamageType.Radiant, + scaling_damage={"per_slot_level": "1d6"} +) + +healing_word = Spell( + name="Healing Word", level=1, school=SpellSchool.Evocation, + casting_time="1 bonus action", range_="60 feet", components="V", + duration="Instantaneous", + description="A creature of your choice that you can see within range regains hit points.", + classes=["Cleric"], heal="1d4", scaling_damage={"per_slot_level": "1d4"} +) + + +# Master lookup: {class_name: {spell_level: [Spell, ...]}} +SPELL_LISTS = { + "Wizard": { + 0: [fire_bolt, ray_of_frost, mage_hand, prestidigitation], + 1: [magic_missile, shield, burning_hands, detect_magic], + }, + "Cleric": { + 0: [sacred_flame, guidance, spare_the_dying], + 1: [cure_wounds, bless, guiding_bolt, healing_word, detect_magic], + }, +} + + +def get_spell_by_name(name): + """Looks up a Spell object across all class lists by (case-insensitive) name.""" + name = name.lower().strip() + for class_spells in SPELL_LISTS.values(): + for level_spells in class_spells.values(): + for spell in level_spells: + if spell.name.lower() == name: + return spell + return None