Add Container class with lock/trap detection, corpse drops, and numbered duplicate corpses

This commit is contained in:
KansaiGaijin
2026-07-07 12:13:43 +12:00
parent db4b5cb543
commit 221cb71410
32 changed files with 938 additions and 160 deletions

8
.idea/.gitignore generated vendored Normal file
View File

@@ -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

8
.idea/Kanjin-Text-RPG.iml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="jdk" jdkName="Python 3.12" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

7
.idea/dictionaries/project.xml generated Normal file
View File

@@ -0,0 +1,7 @@
<component name="ProjectDictionaryState">
<dictionary name="project">
<words>
<w>darkvision</w>
</words>
</dictionary>
</component>

View File

@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

7
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="Python 3.12" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12" project-jdk-type="Python SDK" />
</project>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/Kanjin-Text-RPG.iml" filepath="$PROJECT_DIR$/.idea/Kanjin-Text-RPG.iml" />
</modules>
</component>
</project>

7
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

33
AGENTS.md Normal file
View File

@@ -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).

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,5 +1,14 @@
class Job: # NOTE ON NAMING: this represents a D&D "class" (Barbarian, Cleric, Wizard...), but `class`
def __init__(self, name, description, hitdie, proficiency, savingthrow, skill, gold): # 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.name = name
self.description = description self.description = description
self.hitdie = hitdie self.hitdie = hitdie
@@ -8,6 +17,20 @@ class Job:
self.skill = skill self.skill = skill
self.gold = gold 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: def __repr__(self) -> str:
profs = "" profs = ""
for key, value in self.proficiency.items(): for key, value in self.proficiency.items():
@@ -24,7 +47,7 @@ class Job:
+ '\n'.join(self.skill[1:]).title() + '\n' + '\n'.join(self.skill[1:]).title() + '\n'
class Barbarian(Job): class Barbarian(CharacterClass):
def __init__(self): def __init__(self):
name = "Barbarian" name = "Barbarian"
description = "A tall human tribesman strides through a blizzard, draped in fur and hefting his axe. " \ 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) super().__init__(name, description, hitdie, proficiency, savingthrow, skills, gold)
class Cleric(Job): class Cleric(CharacterClass):
def __init__(self): def __init__(self):
name = "Cleric" name = "Cleric"
description = "Arms and eyes upraised toward the sun and a prayer on his lips, an elf begins to glow with " \ 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" "religion"
] ]
gold = [2, 10] 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): def __init__(self):
name = "Wizard" name = "Wizard"
description = "Clad in the silver robes that denote her station, an elf closes her eyes to shut out the " \ 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" "religion"
] ]
gold = [2, 10] 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() CLASSES = {
Cleric = Cleric() "Barbarian": Barbarian(),
Wizard = Wizard() "Cleric": Cleric(),
"Wizard": Wizard(),
}

View File

@@ -48,3 +48,25 @@ class Slots(Enum):
RightRing = auto() RightRing = auto()
Other = auto() Other = auto()
Attunement = auto() 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)

View File

@@ -4,8 +4,8 @@ sys.path.append('.')
from player import Player # Only import what's necessary from player import Player # Only import what's necessary
from enum_list import ItemType, Slots from enum_list import ItemType, Slots
from race_list import Elf, Dwarf, Human from race_list import RACES
from job_list import Barbarian, Cleric, Wizard from class_list import CLASSES
def wait(): def wait():
@@ -16,8 +16,8 @@ pre_generated_characters = {
"name": "Arion", # Example Name "name": "Arion", # Example Name
"gender": "Male", "gender": "Male",
"age": 25, "age": 25,
"race": Elf, "race": RACES["Elf"],
"job": Wizard, # Based on the High Elf sheet "class_": CLASSES["Wizard"], # Based on the High Elf sheet
"alignment": "lawful good", "alignment": "lawful good",
"description": "A scholarly High Elf skilled in the arcane arts.", "description": "A scholarly High Elf skilled in the arcane arts.",
"stats": { "stats": {
@@ -41,8 +41,8 @@ pre_generated_characters = {
"name": "Brundle", # Example Name "name": "Brundle", # Example Name
"gender": "Female", "gender": "Female",
"age": 35, "age": 35,
"race": Human, "race": RACES["Human"],
"job": Barbarian, # Based on the Human sheet "class_": CLASSES["Barbarian"], # Based on the Human sheet
"alignment": "chaotic good", "alignment": "chaotic good",
"description": "A robust Human warrior, charging into battle.", "description": "A robust Human warrior, charging into battle.",
"stats": { "stats": {
@@ -67,8 +67,8 @@ pre_generated_characters = {
"name": "Drok", # Example Name "name": "Drok", # Example Name
"gender": "Other", "gender": "Other",
"age": 75, # Dwarves live longer "age": 75, # Dwarves live longer
"race": Dwarf, "race": RACES["Dwarf"],
"job": Cleric, # Based on the Hill Dwarf sheet (Life Domain implied by "healing" focus) "class_": CLASSES["Cleric"], # Based on the Hill Dwarf sheet (Life Domain implied by "healing" focus)
"alignment": "lawful good", "alignment": "lawful good",
"description": "A stout Hill Dwarf cleric, a pillar of his community.", "description": "A stout Hill Dwarf cleric, a pillar of his community.",
"stats": { "stats": {
@@ -142,10 +142,10 @@ def startGame():
print("Sorry, I didn't catch that. Please try again.\n") print("Sorry, I didn't catch that. Please try again.\n")
continue continue
# --- Race and Job Selection --- # --- Race and Class Selection ---
race = None race = None
job = None class_ = None
# Loop until Race and Job are confirmed # Loop until Race and Class are confirmed
while True: while True:
# Race Selection # Race Selection
selected_race_obj = None selected_race_obj = None
@@ -154,11 +154,11 @@ def startGame():
race_choice = input("Please select a race to learn more about it:\n" race_choice = input("Please select a race to learn more about it:\n"
"1) Elf\n2) Dwarf\n3) Human\n >> ").title() "1) Elf\n2) Dwarf\n3) Human\n >> ").title()
if race_choice in ['Elf', '1']: if race_choice in ['Elf', '1']:
selected_race_obj = Elf selected_race_obj = RACES["Elf"]
elif race_choice in ['Dwarf', '2']: elif race_choice in ['Dwarf', '2']:
selected_race_obj = Dwarf selected_race_obj = RACES["Dwarf"]
elif race_choice in ['Human', '3']: elif race_choice in ['Human', '3']:
selected_race_obj = Human selected_race_obj = RACES["Human"]
else: else:
print("Sorry I didn't recognise that race. Please select 'Elf', 'Dwarf', or 'Human'.\n") print("Sorry I didn't recognise that race. Please select 'Elf', 'Dwarf', or 'Human'.\n")
continue continue
@@ -177,48 +177,48 @@ def startGame():
print("Sorry, I didn't catch that. Please try again.\n") print("Sorry, I didn't catch that. Please try again.\n")
continue continue
# Job Selection # Class Selection
selected_job_obj = None selected_class_obj = None
# Loop for selecting and viewing job # Loop for selecting and viewing class
while True: 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() "1) Barbarian\n2) Cleric\n3) Wizard\n >> ").title()
if job_choice in ['Barbarian', '1']: if class_choice in ['Barbarian', '1']:
selected_job_obj = Barbarian selected_class_obj = CLASSES["Barbarian"]
elif job_choice in ['Cleric', '2']: elif class_choice in ['Cleric', '2']:
selected_job_obj = Cleric selected_class_obj = CLASSES["Cleric"]
elif job_choice in ['Wizard', '3']: elif class_choice in ['Wizard', '3']:
selected_job_obj = Wizard selected_class_obj = CLASSES["Wizard"]
else: 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 continue
if selected_job_obj: if selected_class_obj:
print(f"\n--- {selected_job_obj.name} ---") print(f"\n--- {selected_class_obj.name} ---")
print(selected_job_obj.__repr__()) print(selected_class_obj.__repr__())
print('Would you like to proceed with this job or view another?') print('Would you like to proceed with this class or view another?')
proceed = input('1) Proceed\n2) View another job\n >> ') proceed = input('1) Proceed\n2) View another class\n >> ')
if proceed.lower() in ('1', 'proceed'): if proceed.lower() in ('1', 'proceed'):
job = selected_job_obj # Assign job object class_ = selected_class_obj # Assign class object
break # Exit job selection loop break # Exit class selection loop
elif proceed.lower() in ('2', 'view', 'view another', 'view another job'): elif proceed.lower() in ('2', 'view', 'view another', 'view another class'):
continue # Restart job selection continue # Restart class selection
else: else:
print("Sorry, I didn't catch that. Please try again.\n") print("Sorry, I didn't catch that. Please try again.\n")
continue continue
# Final confirmation for both Race and Job # Final confirmation for both Race and Class
while True: while True:
# This safeguard should ideally not be hit if inner loops work correctly # This safeguard should ideally not be hit if inner loops work correctly
if race is None or job is None: if race is None or class_ is None:
print("Error: Race or Job not selected. Restarting Race/Job selection.") print("Error: Race or Class not selected. Restarting Race/Class selection.")
break # Break out of this inner loop to restart the outer race/job loop 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} {job.name}.\nIs this correct? Y/N\n >> ") 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']: 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']: 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 break
else: else:
print("Sorry, I didn't catch that. Please try again.\n") print("Sorry, I didn't catch that. Please try again.\n")
@@ -233,19 +233,19 @@ def startGame():
gender = input("Gender (Male, Female, Other): ") gender = input("Gender (Male, Female, Other): ")
age = int(input("Age: ")) age = int(input("Age: "))
race_str = input("Race (Elf, Dwarf, Human): ") 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 # Map string inputs to actual Race/Class objects for consistency
race_map = {'elf': Elf, 'dwarf': Dwarf, 'human': Human} race_map = {'elf': RACES["Elf"], 'dwarf': RACES["Dwarf"], 'human': RACES["Human"]}
job_map = {'barbarian': Barbarian, 'cleric': Cleric, 'wizard': Wizard} class_map = {'barbarian': CLASSES["Barbarian"], 'cleric': CLASSES["Cleric"], 'wizard': CLASSES["Wizard"]}
actual_race = race_map.get(race_str.lower()) 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: if actual_race and actual_class:
return name, gender, age, actual_race, actual_job, None return name, gender, age, actual_race, actual_class, None
else: 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" start = "1"
continue continue
return None return None
@@ -267,7 +267,7 @@ def startGame():
print("Who would you like to to play?") print("Who would you like to to play?")
for key, char_data in pre_generated_characters.items(): for key, char_data in pre_generated_characters.items():
print( 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.") print("Type 'back' to return to character creation options.")
char_choice = input(">> ").strip().lower() char_choice = input(">> ").strip().lower()
@@ -284,7 +284,7 @@ def startGame():
print(f"Gender: {selected_char_data['gender']}") print(f"Gender: {selected_char_data['gender']}")
print(f"Age: {selected_char_data['age']}") print(f"Age: {selected_char_data['age']}")
print(f"Race: {selected_char_data['race'].name_adjective}") 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"Alignment: {selected_char_data['alignment']}")
print(f"Description: {selected_char_data['description']}") print(f"Description: {selected_char_data['description']}")
print(f"Age: {selected_char_data['age']}") print(f"Age: {selected_char_data['age']}")
@@ -340,11 +340,11 @@ def startGame():
gender = selected_char_data['gender'] gender = selected_char_data['gender']
age = selected_char_data['age'] age = selected_char_data['age']
race = selected_char_data['race'] race = selected_char_data['race']
job = selected_char_data['job'] class_ = selected_char_data['class_']
pre_allocated_stats = selected_char_data['stats'] pre_allocated_stats = selected_char_data['stats']
print(f"\nYou have selected: {name}, a {age} year old {race.name_adjective} {job.name}.") print(f"\nYou have selected: {name}, a {age} year old {race.name_adjective} {class_.name}.")
return name, gender, age, race, job, pre_allocated_stats # Exit all loops and function return name, gender, age, race, class_, pre_allocated_stats # Exit all loops and function
elif confirm_choice in ("2", "no"): elif confirm_choice in ("2", "no"):
print("\nReturning to pre-generated character selection.") print("\nReturning to pre-generated character selection.")
@@ -532,7 +532,13 @@ def get_instructions():
"Enter cave | house | room - maybe... TBC\n" "Enter cave | house | room - maybe... TBC\n"
"Scene or Location - Replays the current area's details\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" "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) print(descrip1)
time.sleep(3) # Reduced sleep for faster testing time.sleep(3) # Reduced sleep for faster testing
print(descrip2) print(descrip2)
@@ -580,6 +586,24 @@ def parse(input_text):
command = "go" command = "go"
object1 = " ".join(words[1:]) object1 = " ".join(words[1:])
return command, object1 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 # Single-word commands
if words[0] == "help": if words[0] == "help":
@@ -630,6 +654,14 @@ def parse(input_text):
command = "drop" # User needs to specify what to drop command = "drop" # User needs to specify what to drop
object1 = None 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": elif words[0] == "quit":
command = "quit" command = "quit"
else: else:

139
main.py
View File

@@ -1,4 +1,5 @@
import sys import sys
from random import randint
# Add the current directory to sys.path to allow imports from sibling files # Add the current directory to sys.path to allow imports from sibling files
sys.path.append('.') sys.path.append('.')
from player import Player from player import Player
@@ -36,8 +37,8 @@ class Engine:
# Display lootable containers # Display lootable containers
if self.current_scene.lootable_items: if self.current_scene.lootable_items:
print("\nYou also notice:") print("\nYou also notice:")
for container_name, items_in_container in self.current_scene.lootable_items.items(): for container_name, 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 if container.has_items():
print(f" - {container_name.title()}") print(f" - {container_name.title()}")
def look_around(self): def look_around(self):
@@ -53,8 +54,8 @@ class Engine:
# Display lootable containers # Display lootable containers
if self.current_scene.lootable_items: if self.current_scene.lootable_items:
print("\nYou also notice:") print("\nYou also notice:")
for container_name, items_in_container in self.current_scene.lootable_items.items(): for container_name, 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 if container.has_items():
print(f" - {container_name.title()}") print(f" - {container_name.title()}")
@@ -116,19 +117,45 @@ class Engine:
if not found_in_scene: if not found_in_scene:
# Check lootable containers in the current scene # Check lootable containers in the current scene
found_in_loot = False found_in_loot = False
for container_name, items_in_container in self.current_scene.lootable_items.items(): for container_name, container in self.current_scene.lootable_items.items():
if object_name.lower() in container_name.lower(): # If user tries to examine the container itself if object_name.lower() in container_name.lower():
print(f"\nYou examine the {container_name}. Inside, you see:") print(f"\n--- {container.name.title()} ---")
if items_in_container: print(container.description)
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 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: if object_name.lower() in item_obj.name.lower() and count > 0:
print(f"\n--- {item_obj.name} ---") print(f"\n--- {item_obj.name} ---")
print(item_obj.description) print(item_obj.description)
@@ -185,22 +212,32 @@ class Engine:
def loot_container(self, container_name): def loot_container(self, container_name):
"""Allows the player to loot items from a specified container.""" """Allows the player to loot items from a specified container."""
container_found = False for current_name, container in list(self.current_scene.lootable_items.items()):
for current_container_name, items_in_container in list(self.current_scene.lootable_items.items()): if current_name.lower() == container_name.lower():
if current_container_name.lower() == container_name.lower(): if container.is_locked:
container_found = True print(f"The {container_name} is locked.")
if not items_in_container: 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.") print(f"The {container_name} is empty.")
return return
print(f"You look inside the {container_name}. You see:") 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: while True:
for i, (item_obj, count) in enumerate(loot_list, 1): for i, (item_obj, count) in enumerate(loot_list, 1):
print(f"{i}) {item_obj.name} (x{count})") print(f"{i}) {item_obj.name} (x{count})")
take_all_option = len(loot_list) + 1 take_all_option = len(loot_list) + 1
leave_option = len(loot_list) + 2 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() 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): 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) self.player.inventory.add_item(item_obj, count)
print(f"- Took {count} {item_obj.name}") 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.") print(f"The {container_name} is now empty.")
if not container.has_items():
del self.current_scene.lootable_items[current_name]
return return
elif choice == "leave" or (choice.isdigit() and int(choice) == leave_option): elif choice == "leave" or (choice.isdigit() and int(choice) == leave_option):
print(f"You leave the {container_name} untouched.") print(f"You leave the {container_name} untouched.")
@@ -239,18 +277,16 @@ class Engine:
if 0 < take_count <= current_count: if 0 < take_count <= current_count:
self.player.inventory.add_item(item_obj, take_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}.") print(f"You took {take_count} {item_obj.name}.")
if items_in_container[item_obj] <= 0: if item_obj not in container.contents:
del items_in_container[item_obj] loot_list = list(container.contents.items())
loot_list = list(items_in_container.items())
if not items_in_container: if not container.contents:
print(f"The {container_name} is now empty.")
return
elif not loot_list:
print(f"The {container_name} is now empty.") print(f"The {container_name} is now empty.")
if not container.has_items():
del self.current_scene.lootable_items[current_name]
return return
else: else:
print("Invalid amount or not enough items.") print("Invalid amount or not enough items.")
@@ -260,8 +296,7 @@ class Engine:
print("Invalid selection. Please enter a number, 'take all', or 'leave'.") print("Invalid selection. Please enter a number, 'take all', or 'leave'.")
return 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 # Initialize the game engine
@@ -272,10 +307,10 @@ def main_game_loop():
The main loop for the game, handling character creation and continuous gameplay. The main loop for the game, handling character creation and continuous gameplay.
""" """
# Character Creation # Character Creation
name, gender, age, race, job, pre_allocated_stats = startGame() name, gender, age, race, class_, pre_allocated_stats = startGame()
# Create player instance # 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 game_engine.set_player(player) # Set the player in the game engine
# Display initial character summary # Display initial character summary
@@ -286,13 +321,16 @@ def main_game_loop():
# For 'Pre-generated Character' # For 'Pre-generated Character'
game_engine.player.stats = pre_allocated_stats game_engine.player.stats = pre_allocated_stats
game_engine.player.getModifier() 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 game_engine.player.current_stats() # Display stats after setting them
print(' ') print(' ')
game_engine.player.inventory.current_equipment() game_engine.player.inventory.current_equipment()
print(' ') print(' ')
game_engine.player.inventory.current_inventory() game_engine.player.inventory.current_inventory()
print(' ') print(' ')
if game_engine.player.class_.is_caster:
game_engine.player.print_spellbook()
print(' ')
wait() wait()
print("\nYour adventure begins...") print("\nYour adventure begins...")
@@ -340,6 +378,29 @@ def main_game_loop():
elif command == "go": elif command == "go":
direction = obj1 direction = obj1
game_engine.move_to_scene(direction) 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": elif command == "quit":
print("Thanks for playing!") print("Thanks for playing!")
break break

339
player.py
View File

@@ -1,16 +1,33 @@
import re
from random import randint 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 # Define Item classes here, as they are fundamental building blocks
class Item: class Item:
"""The base class for all items""" """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.name = name
self.description = description self.description = description
self.value = value self.value = value
self.magical = magical self.magical = magical
self.itemType = itemType self.itemType = itemType
self.attunement = attunement self.attunement = attunement
self.weight = weight # in pounds, per SRD carrying capacity rules
def __repr__(self): def __repr__(self):
return f"{self.name}\n=====\n{self.description}\nValue: {self.value}\n" return f"{self.name}\n=====\n{self.description}\nValue: {self.value}\n"
@@ -29,13 +46,14 @@ class Money(Item):
value=self.amt, value=self.amt,
magical=self.magical, magical=self.magical,
itemType=ItemType.Money, itemType=ItemType.Money,
attunement=False) attunement=False,
weight=0.02) # SRD: 50 coins weigh 1 lb, regardless of denomination
class Weapon(Item): class Weapon(Item):
"""The base class for all weapons""" """The base class for all weapons"""
def __init__(self, name, description, value, slot, damage1H, damage2H, versatiledmg, light, finesse, 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.slot = slot
self.damage1H = damage1H self.damage1H = damage1H
self.damage2H = damage2H self.damage2H = damage2H
@@ -47,7 +65,7 @@ class Weapon(Item):
self.versatile = versatile self.versatile = versatile
self.thrown = thrown self.thrown = thrown
self.itemType = ItemType self.itemType = ItemType
super().__init__(name, description, value, magical, ItemType, attunement) super().__init__(name, description, value, magical, ItemType, attunement, weight)
def __str__(self): def __str__(self):
if self.damage2H is None and self.damage1H is not None: if self.damage2H is None and self.damage1H is not None:
@@ -84,14 +102,15 @@ class Weapon(Item):
class Armor(Item): class Armor(Item):
"""The base class for all armor""" """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.slot = slot
self.grade = grade self.grade = grade
self.ac = ac self.ac = ac
self.stealthDis = disadvantage self.stealthDis = disadvantage
self.dmgRes = dmgRes self.dmgRes = dmgRes
self.itemType = ItemType self.itemType = ItemType
super().__init__(name, description, value, magical, ItemType, attunement) super().__init__(name, description, value, magical, ItemType, attunement, weight)
def __repr__(self): def __repr__(self):
if self.stealthDis: if self.stealthDis:
@@ -299,6 +318,65 @@ class Inventory:
attune_count += 1 attune_count += 1
self.equipped_items[Slots.Attunement] = attune_count 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) --- # --- Item Instances (Moved here for clarity, but still defined once) ---
# Different types of coins # Different types of coins
@@ -323,7 +401,8 @@ rock = Weapon(
thrown=True, thrown=True,
magical=False, magical=False,
ItemType=ItemType.Weapon, ItemType=ItemType.Weapon,
attunement=False attunement=False,
weight=2
) )
dagger = Weapon( dagger = Weapon(
@@ -342,7 +421,8 @@ dagger = Weapon(
thrown=True, thrown=True,
magical=False, magical=False,
ItemType=ItemType.Weapon, ItemType=ItemType.Weapon,
attunement=False attunement=False,
weight=1
) )
polearm = Weapon( polearm = Weapon(
@@ -361,7 +441,8 @@ polearm = Weapon(
thrown=False, # Polearms are not typically thrown thrown=False, # Polearms are not typically thrown
magical=False, magical=False,
ItemType=ItemType.Weapon, ItemType=ItemType.Weapon,
attunement=False attunement=False,
weight=6
) )
tornRags = Armor( tornRags = Armor(
@@ -375,7 +456,8 @@ tornRags = Armor(
dmgRes="None", dmgRes="None",
magical=False, magical=False,
ItemType=ItemType.Armor, ItemType=ItemType.Armor,
attunement=False attunement=False,
weight=2
) )
paper = Item( paper = Item(
@@ -384,19 +466,22 @@ paper = Item(
value=None, value=None,
magical=False, magical=False,
itemType=ItemType.Item, itemType=ItemType.Item,
attunement=False attunement=False,
weight=0
) )
class Player: class Player:
"""Character Creation""" """Character Creation"""
def __init__(self, name, gender, age, race, job): def __init__(self, name, gender, age, race, class_):
# Identity # Identity
self.name = name self.name = name
self.gender = gender self.gender = gender
self.age = age self.age = age
self.race = race 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 # Defence
self.ac = 0 # This will be calculated based on equipped armor self.ac = 0 # This will be calculated based on equipped armor
@@ -423,6 +508,19 @@ class Player:
# Equipment and Inventory # Equipment and Inventory
self.inventory = Inventory() # Each player gets their own inventory instance 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 # Ability Scores
self.stats = {"Strength": 0, self.stats = {"Strength": 0,
"Dexterity": 0, "Dexterity": 0,
@@ -540,22 +638,198 @@ class Player:
print("As a Dwarf, your constitution increases by 2.") print("As a Dwarf, your constitution increases by 2.")
self.stats["Constitution"] += 2 self.stats["Constitution"] += 2
def set_stats_job(self): def set_stats_class(self):
"""Applies job-specific stats like hit points.""" """Applies class-specific stats like hit points."""
# Using self.job.name to access the job name from the Job object # Using self.class_.name to access the class name from the CharacterClass object
if self.job.name.lower() == "barbarian": if self.class_.name.lower() == "barbarian":
self.hitDie = randint(self.job.hitdie[0], self.job.hitdie[1]) # Use job's hitdie range self.hitDie = randint(self.class_.hitdie[0], self.class_.hitdie[1]) # Use class's hitdie range
self.maxHP = (self.job.hitdie[1] + self.mods["Constitution"]) # Use max hitdie value for initial HP self.maxHP = (self.class_.hitdie[1] + self.mods["Constitution"]) # Use max hitdie value for initial HP
self.currentHP = self.maxHP self.currentHP = self.maxHP
elif self.job.name.lower() == "cleric": elif self.class_.name.lower() == "cleric":
self.hitDie = randint(self.job.hitdie[0], self.job.hitdie[1]) self.hitDie = randint(self.class_.hitdie[0], self.class_.hitdie[1])
self.maxHP = (self.job.hitdie[1] + self.mods["Constitution"]) self.maxHP = (self.class_.hitdie[1] + self.mods["Constitution"])
self.currentHP = self.maxHP self.currentHP = self.maxHP
elif self.job.name.lower() == "wizard": elif self.class_.name.lower() == "wizard":
self.hitDie = randint(self.job.hitdie[0], self.job.hitdie[1]) self.hitDie = randint(self.class_.hitdie[0], self.class_.hitdie[1])
self.maxHP = (self.job.hitdie[1] + self.mods["Constitution"]) self.maxHP = (self.class_.hitdie[1] + self.mods["Constitution"])
self.currentHP = self.maxHP 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 <spell>'.
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): def current_stats(self):
"""Prints a display of the user's current statistics.""" """Prints a display of the user's current statistics."""
print(f'\nYour current stats are:') print(f'\nYour current stats are:')
@@ -584,18 +858,29 @@ class Player:
self.allocation() # 1. Sets base self.stats from rolls. self.allocation() # 1. Sets base self.stats from rolls.
self.set_stats_race() # 2. Applies racial bonuses to self.stats. 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.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. self.current_stats() # 5. Displays the final stats.
print(' ') print(' ')
self.inventory.current_equipment() # Corrected to call instance method self.inventory.current_equipment() # Corrected to call instance method
print(' ') print(' ')
self.inventory.current_inventory() self.inventory.current_inventory()
print(' ') print(' ')
if self.class_.is_caster:
self.print_spellbook()
print(' ')
def health_check(self): def health_check(self):
"""Print out current/max HP""" """Print out current/max HP"""
print(f'You have {self.currentHP}/{self.maxHP} 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): def take_damage(self, DMGtype, size):
"""Define the damage type and dice size""" """Define the damage type and dice size"""
damage = randint(1, size) damage = randint(1, size)

View File

@@ -39,7 +39,7 @@ class Elf(Race):
speed = { speed = {
'Walking': 30 'Walking': 30
} }
darkvision = [30, 60] darkvision = 60
language = ["Common", "Elvish"] language = ["Common", "Elvish"]
advantage = [] advantage = []
resistance = [] resistance = []
@@ -155,6 +155,8 @@ class Human(Race):
language, advantage, resistance, proficiency, traits) language, advantage, resistance, proficiency, traits)
Elf = Elf() RACES = {
Dwarf = Dwarf() "Elf": Elf(),
Human = Human() "Dwarf": Dwarf(),
"Human": Human(),
}

View File

@@ -5,6 +5,36 @@ sys.path.append('.')
from player import Item, Weapon, Armor, rock, dagger, polearm, tornRags, paper, GP1, SP1, CP1 from player import Item, Weapon, Armor, rock, dagger, polearm, tornRags, paper, GP1, SP1, CP1
from enum_list import * 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: class Scene:
""" """
Represents a single location or area in the game. Represents a single location or area in the game.
@@ -45,15 +75,12 @@ class Scene:
return self.exits.get(direction.lower()) return self.exits.get(direction.lower())
def add_lootable_item(self, container_name, item_obj, count=1): 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: if container_name not in self.lootable_items:
self.lootable_items[container_name] = {} self.lootable_items[container_name] = Container(container_name, "")
self.lootable_items[container_name][item_obj] = self.lootable_items[container_name].get(item_obj, 0) + count 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): def add_available_item(self, item_obj, count=1):
""" """
@@ -75,18 +102,31 @@ class Scene:
del self.available_items[item_obj] del self.available_items[item_obj]
def remove_lootable_item(self, container_name, item_obj, count=1): 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. base_name = f"{enemy_name.lower()} corpse"
:param container_name: The name of the container. name = base_name
:param item_obj: The Item object to remove. counter = 1
:param count: The quantity to remove. while name in self.lootable_items:
""" counter += 1
if container_name in self.lootable_items and item_obj in self.lootable_items[container_name]: name = f"{base_name} {counter}"
self.lootable_items[container_name][item_obj] -= count
if self.lootable_items[container_name][item_obj] <= 0: corpse = Container(name, f"The lifeless body of a {enemy_name}.")
del self.lootable_items[container_name][item_obj] if isinstance(items, dict):
if not self.lootable_items[container_name]: # If container is empty, remove it for item_obj, count in items.items():
del self.lootable_items[container_name] 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 --- # --- Default Scenes ---
@@ -112,9 +152,9 @@ tutorial = Scene(
) )
tutorial.add_available_item(rock, 1) tutorial.add_available_item(rock, 1)
tutorial.add_available_item(paper, 2) tutorial.add_available_item(paper, 2)
tutorial.add_lootable_item("suspicious tree trunk", Item("Test Item", tutorial_trunk = Container("suspicious tree trunk", "A hollow tree trunk with a small gap in the bark.")
"This is for testing purposes", None, tutorial_trunk.add_item(Item("Test Item", "This is for testing purposes", None, False, ItemType.Item, False), 1)
False, ItemType.Item, False), 1) tutorial.add_container(tutorial_trunk)
# Scene 1: Starting Clearing # Scene 1: Starting Clearing
starting_clearing = Scene( starting_clearing = Scene(
@@ -125,8 +165,10 @@ starting_clearing = Scene(
"You notice a small, weathered wooden chest near a fallen log." "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_available_item(rock, 3) # Rocks lying on the ground
starting_clearing.add_lootable_item("small wooden chest", GP1, 15) starting_clearing_chest = Container("small wooden chest", "A small, weathered wooden chest with rusty iron bands.")
starting_clearing.add_lootable_item("small wooden chest", SP1, 20) 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 # Scene 2: Forest Path

221
spell_list.py Normal file
View File

@@ -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