Add Container class with lock/trap detection, corpse drops, and numbered duplicate corpses
This commit is contained in:
339
player.py
339
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 <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):
|
||||
"""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)
|
||||
|
||||
Reference in New Issue
Block a user