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