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