adding weapons with charges

This commit is contained in:
evilchili 2024-08-29 15:14:47 -07:00
parent 5d9fde949d
commit 5c80565264
5 changed files with 160 additions and 27 deletions

View File

@ -1,5 +1,6 @@
import itertools import itertools
from collections import defaultdict from collections import defaultdict
from typing import List
from sqlalchemy import ForeignKey, String, Text, UniqueConstraint from sqlalchemy import ForeignKey, String, Text, UniqueConstraint
from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.ext.associationproxy import association_proxy
@ -261,15 +262,16 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
ancestry_id: Mapped[int] = mapped_column(ForeignKey("ancestry.id"), nullable=False, default="1") ancestry_id: Mapped[int] = mapped_column(ForeignKey("ancestry.id"), nullable=False, default="1")
ancestry: Mapped["Ancestry"] = relationship(uselist=False, default=None) ancestry: Mapped["Ancestry"] = relationship(uselist=False, default=None)
_inventories = relationship("Inventory", uselist=True, cascade="all,delete,delete-orphan", lazy="immediate") inventories: Mapped[List["Inventory"]] = relationship(
inventories = association_proxy("_inventories", "id", creator=inventory_creator) uselist=True, cascade="all,delete,delete-orphan", lazy="immediate", default_factory=lambda: []
)
_hit_dice = relationship("HitDie", uselist=True, cascade="all,delete,delete-orphan", lazy="immediate") _hit_dice = relationship("HitDie", uselist=True, cascade="all,delete,delete-orphan", lazy="immediate")
_spell_slots = relationship("SpellSlot", uselist=True, cascade="all,delete,delete-orphan", lazy="immediate") _spell_slots = relationship("SpellSlot", uselist=True, cascade="all,delete,delete-orphan", lazy="immediate")
@property @property
def spells(self): def spells(self):
return [inv for inv in self._inventories if inv.inventory_type == InventoryType.SPELL][0] return [inv for inv in self.inventories if inv.inventory_type == InventoryType.SPELL][0]
@property @property
def prepared_spells(self): def prepared_spells(self):
@ -394,7 +396,7 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
@property @property
def equipment(self): def equipment(self):
return [inv for inv in self._inventories if inv.inventory_type == InventoryType.EQUIPMENT][0] return [inv for inv in self.inventories if inv.inventory_type == InventoryType.EQUIPMENT][0]
@property @property
def equipped_items(self): def equipped_items(self):
@ -701,6 +703,6 @@ class Character(BaseObject, SlugMixin, ModifierMixin):
): ):
self.add_skill(skill, proficient=False, expert=False) self.add_skill(skill, proficient=False, expert=False)
self._inventories.append(Inventory(inventory_type=InventoryType.EQUIPMENT, character_id=self.id)) self.inventories.append(Inventory(inventory_type=InventoryType.EQUIPMENT, character_id=self.id))
self._inventories.append(Inventory(inventory_type=InventoryType.SPELL, character_id=self.id)) self.inventories.append(Inventory(inventory_type=InventoryType.SPELL, character_id=self.id))
session.add(self) session.add(self)

View File

@ -1,9 +1,10 @@
from typing import List
from sqlalchemy import ForeignKey, UniqueConstraint from sqlalchemy import ForeignKey, UniqueConstraint
from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from ttfrog.db.base import BaseObject, EnumField from ttfrog.db.base import BaseObject, EnumField
from ttfrog.db.schema.item import Item, ItemType from ttfrog.db.schema.item import Item, ItemProperty, ItemType
class InventoryType(EnumField): class InventoryType(EnumField):
@ -36,11 +37,15 @@ class Inventory(BaseObject):
character_id: Mapped[int] = mapped_column(ForeignKey("character.id")) character_id: Mapped[int] = mapped_column(ForeignKey("character.id"))
inventory_type: Mapped[InventoryType] = mapped_column(nullable=False) inventory_type: Mapped[InventoryType] = mapped_column(nullable=False)
_inventory_map = relationship("InventoryMap", lazy="immediate", uselist=True, cascade="all,delete,delete-orphan") items: Mapped[List["InventoryMap"]] = relationship(
inventory_map = association_proxy("_inventory_map", "id", creator=inventory_map_creator) uselist=True, cascade="all,delete,delete-orphan", lazy="immediate", default_factory=lambda: []
)
def get(self, item): def get(self, item):
return [mapping for mapping in self._inventory_map if mapping.item == item] return self.get_all(item)[0]
def get_all(self, item):
return [mapping for mapping in self.items if mapping.item == item]
def add(self, item): def add(self, item):
if item.item_type not in inventory_type_map[self.inventory_type]: if item.item_type not in inventory_type_map[self.inventory_type]:
@ -48,23 +53,25 @@ class Inventory(BaseObject):
mapping = InventoryMap(inventory_id=self.id, item_id=item.id) mapping = InventoryMap(inventory_id=self.id, item_id=item.id)
if item.consumable: if item.consumable:
mapping.count = item.count mapping.count = item.count
self.inventory_map.append(mapping) if item.charges:
mapping.charges = [Charge(inventory_map_id=mapping.id) for i in range(item.charges)]
self.items.append(mapping)
return mapping return mapping
def remove(self, mapping): def remove(self, mapping):
if mapping.id not in self.inventory_map: if mapping not in self.items:
return False return False
self.inventory_map.remove(mapping.id) self.items.remove(mapping)
return True return True
def __contains__(self, obj): def __contains__(self, obj):
for mapping in self._inventory_map: for mapping in self.items:
if mapping.item == obj: if mapping.item == obj:
return True return True
return False return False
def __iter__(self): def __iter__(self):
yield from self._inventory_map yield from self.items
class InventoryMap(BaseObject): class InventoryMap(BaseObject):
@ -81,12 +88,32 @@ class InventoryMap(BaseObject):
always_prepared: Mapped[bool] = mapped_column(default=False) always_prepared: Mapped[bool] = mapped_column(default=False)
charges: Mapped[List["Charge"]] = relationship(
uselist=True, cascade="all,delete,delete-orphan", lazy="immediate", default_factory=lambda: []
)
@property
def charges_available(self):
return [charge for charge in self.charges if not charge.expended]
@property @property
def prepared(self): def prepared(self):
if self.item.item_type == ItemType.SPELL: if self.item.item_type == ItemType.SPELL:
return self.equipped or self.always_prepared return self.equipped or self.always_prepared
def use(self, count=1): def use(self, item_property: ItemProperty, charges=None):
if item_property.charge_cost is None:
return True
avail = self.charges_available
if charges is None:
charges = item_property.charge_cost
if len(avail) < charges:
return False
for charge in avail:
charge.expended = True
return True
def consume(self, count=1):
if count < 0: if count < 0:
return False return False
if not self.item.consumable: if not self.item.consumable:
@ -98,3 +125,10 @@ class InventoryMap(BaseObject):
self.inventory.remove(self) self.inventory.remove(self)
return 0 return 0
return self.count return self.count
class Charge(BaseObject):
__tablename__ = "charge"
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
inventory_map_id: Mapped[int] = mapped_column(ForeignKey("inventory_map.id"))
expended: Mapped[bool] = mapped_column(nullable=False, default=False)

View File

@ -1,3 +1,5 @@
from typing import List
from sqlalchemy import ForeignKey, String from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@ -21,16 +23,36 @@ ITEM_TYPES = [
"SHIELD", "SHIELD",
] ]
RECHARGE_TIMES = [
"short rest",
"long rest",
"dawn",
]
COST_TYPES = ["Action", "Bonus Action", "Reaction"]
RARITY = ["Common", "Uncommon", "Rare", "Very Rare", "Legendary", "Artifact"] RARITY = ["Common", "Uncommon", "Rare", "Very Rare", "Legendary", "Artifact"]
ItemType = EnumField("ItemType", ((k, k) for k in ITEM_TYPES)) ItemType = EnumField("ItemType", ((k, k) for k in ITEM_TYPES))
Rarity = EnumField("Rarity", ((k, k) for k in RARITY)) Rarity = EnumField("Rarity", ((k, k) for k in RARITY))
RechargeTime = EnumField("RechargeTime", ((k.replace(" ", "_").upper(), k) for k in RECHARGE_TIMES))
Cost = EnumField("Cost", ((k, k) for k in COST_TYPES))
def item_property_creator(fields):
if isinstance(fields, list):
for f in fields:
yield f
elif isinstance(fields, ItemProperty):
return fields
return ItemProperty(**fields)
class Item(BaseObject, ModifierMixin): class Item(BaseObject, ModifierMixin):
__tablename__ = "item" __tablename__ = "item"
__mapper_args__ = {"polymorphic_identity": ItemType.ITEM, "polymorphic_on": "item_type"} __mapper_args__ = {"polymorphic_identity": ItemType.ITEM, "polymorphic_on": "item_type"}
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(collation="NOCASE"), nullable=False, unique=True) name: Mapped[str] = mapped_column(String(collation="NOCASE"), nullable=False, unique=True)
description: Mapped[str] = mapped_column(String, nullable=True, default=None) description: Mapped[str] = mapped_column(String, nullable=True, default=None)
@ -42,12 +64,24 @@ class Item(BaseObject, ModifierMixin):
consumable: Mapped[bool] = mapped_column(default=False) consumable: Mapped[bool] = mapped_column(default=False)
count: Mapped[int] = mapped_column(nullable=False, default=1) count: Mapped[int] = mapped_column(nullable=False, default=1)
charges: Mapped[int] = mapped_column(nullable=True, info={"min": 0}, default=None)
recharge_time: Mapped[RechargeTime] = mapped_column(default=RechargeTime.LONG_REST)
recharge_amount: Mapped[str] = mapped_column(String(collation="NOCASE"), default="1")
_class_restrictions: Mapped[int] = mapped_column(ForeignKey("character_class.id"), nullable=True, default=None) _class_restrictions: Mapped[int] = mapped_column(ForeignKey("character_class.id"), nullable=True, default=None)
class_restrictions: Mapped["CharacterClass"] = relationship(init=False) class_restrictions: Mapped["CharacterClass"] = relationship(init=False)
properties: Mapped[List["ItemProperty"]] = relationship(
uselist=True, cascade="all,delete,delete-orphan", lazy="immediate", default_factory=lambda: []
)
# _spells: Mapped[int] = mapped_column(ForeignKey("spell.id"), nullable=True, default=None) # _spells: Mapped[int] = mapped_column(ForeignKey("spell.id"), nullable=True, default=None)
# spells: Mapped["Spell"] = relationship(init=False) # spells: Mapped["Spell"] = relationship(init=False)
@property
def has_charges(self):
return self.charges is not None
class Spell(Item): class Spell(Item):
__tablename__ = "spell" __tablename__ = "spell"
@ -83,6 +117,7 @@ class Weapon(Item):
versatile: Mapped[bool] = mapped_column(default=False) versatile: Mapped[bool] = mapped_column(default=False)
silvered: Mapped[bool] = mapped_column(default=False) silvered: Mapped[bool] = mapped_column(default=False)
adamantine: Mapped[bool] = mapped_column(default=False) adamantine: Mapped[bool] = mapped_column(default=False)
magical: Mapped[bool] = mapped_column(default=False)
@property @property
def ranged(self): def ranged(self):
@ -101,3 +136,15 @@ class Armor(Item):
__mapper_args__ = {"polymorphic_identity": ItemType.ARMOR} __mapper_args__ = {"polymorphic_identity": ItemType.ARMOR}
id: Mapped[int] = mapped_column(ForeignKey("item.id"), primary_key=True, init=False) id: Mapped[int] = mapped_column(ForeignKey("item.id"), primary_key=True, init=False)
item_type: Mapped[ItemType] = ItemType.ARMOR item_type: Mapped[ItemType] = ItemType.ARMOR
class ItemProperty(BaseObject):
__tablename__ = "item_property"
id: Mapped[int] = mapped_column(init=False, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(collation="NOCASE"), nullable=False, unique=True)
description: Mapped[str] = mapped_column(String, nullable=True, default=None)
charge_cost: Mapped[int] = mapped_column(nullable=True, info={"min": 0}, default=None)
item_id: Mapped[int] = mapped_column(ForeignKey("item.id"), default=0)
# action/reaction/bonus
# modifiers?

View File

@ -24,7 +24,7 @@ def test_equipment_inventory(db, carl):
carl.equipment.add(ten_foot_pole) carl.equipment.add(ten_foot_pole)
db.add_or_update(carl) db.add_or_update(carl)
all_carls_poles = carl.equipment.get(ten_foot_pole) all_carls_poles = carl.equipment.get_all(ten_foot_pole)
assert len(all_carls_poles) == 3 assert len(all_carls_poles) == 3
# check the "contains" logic # check the "contains" logic
@ -71,17 +71,17 @@ def test_inventory_bundles(db, carl):
assert quiver.count == 20 assert quiver.count == 20
# use one # use one
assert quiver.use(1) == 19 assert quiver.consume(1) == 19
assert quiver.count == 19 assert quiver.count == 19
# cannot use more than you have # cannot use more than you have
assert not quiver.use(20) assert not quiver.consume(20)
# cannot use a negative amount # cannot use a negative amount
assert not quiver.use(-1) assert not quiver.consume(-1)
# consume all remaining arrows # consume all remaining arrows
assert quiver.use(19) == 0 assert quiver.consume(19) == 0
assert arrows not in carl.equipment assert arrows not in carl.equipment
@ -107,12 +107,12 @@ def test_spell_slots(db, carl, wizard):
assert fireball not in carl.prepared_spells assert fireball not in carl.prepared_spells
# prepare the cantrip # prepare the cantrip
carls_prestidigitation = carl.spells.get(prestidigitation)[0] carls_prestidigitation = carl.spells.get(prestidigitation)
assert carl.prepare(carls_prestidigitation) assert carl.prepare(carls_prestidigitation)
assert carl.cast(carls_prestidigitation) assert carl.cast(carls_prestidigitation)
# prepare() and cast() require a spell from the spell inventory # prepare() and cast() require a spell from the spell inventory
carls_fireball = carl.spells.get(fireball)[0] carls_fireball = carl.spells.get(fireball)
# can't prepare a 3rd level spell if you don't have 3rd level slots # can't prepare a 3rd level spell if you don't have 3rd level slots
assert carl.spellcaster_level == 1 assert carl.spellcaster_level == 1

View File

@ -1,5 +1,5 @@
from ttfrog.db.schema.constants import DamageType, Defenses from ttfrog.db.schema.constants import DamageType, Defenses
from ttfrog.db.schema.item import Armor, Rarity, Shield, Weapon from ttfrog.db.schema.item import Armor, ItemProperty, Rarity, RechargeTime, Shield, Weapon
from ttfrog.db.schema.modifiers import Modifier from ttfrog.db.schema.modifiers import Modifier
@ -40,6 +40,56 @@ def test_weapons(db):
assert dagger.melee assert dagger.melee
def test_charges(db, carl):
with db.transaction():
for_the_lulz = ItemProperty(
name="For the Lulz",
description="""
On a hit against a creature with a mouth, spend one charge to force the target to roll a DC 13 Wisdom
saving throw. On a failure, the target is forced to grin for one minute. While grinning, the target
cannot speak. The target can repeat the saving throw at the start of their turn."
""",
charge_cost=1,
)
# from sqlalchemy.orm import relationship
# help(relationship)
dagger_of_lulz = Weapon(
name="Dagger of Lulz",
description="This magical dagger has 6 charges. It regains 1d6 charges after a short rest.",
damage_die="1d4",
damage_type=DamageType.slashing,
melee=True,
finesse=True,
light=True,
thrown=True,
attack_range=20,
attack_range_long=60,
magical=True,
charges=6,
recharge_time=RechargeTime.SHORT_REST,
recharge_amount="1d6",
rarity=Rarity["Very Rare"],
requires_attunement=True,
properties=[for_the_lulz],
)
db.add_or_update([carl, dagger_of_lulz])
assert for_the_lulz in dagger_of_lulz.properties
assert carl.equipment.add(dagger_of_lulz)
db.add_or_update(carl)
carls_dagger = carl.equipment.get(dagger_of_lulz)
assert carl.equip(carls_dagger)
assert carl.attune(carls_dagger)
assert len(carls_dagger.charges) == dagger_of_lulz.charges == 6
assert len(carls_dagger.charges_available) == dagger_of_lulz.charges == 6
assert carls_dagger.use(for_the_lulz)
def test_attunement(db, carl): def test_attunement(db, carl):
with db.transaction(): with db.transaction():
helm = Armor( helm = Armor(
@ -77,7 +127,7 @@ def test_attunement(db, carl):
assert carl.equipment.add(helm) assert carl.equipment.add(helm)
db.add_or_update(carl) db.add_or_update(carl)
carls_shield = carl.equipment.get(shield)[0] carls_shield = carl.equipment.get(shield)
assert carl.armor_class == 10 assert carl.armor_class == 10
assert len(carl.attuned_items) == 0 assert len(carl.attuned_items) == 0
@ -95,7 +145,7 @@ def test_attunement(db, carl):
assert ranged_resistance in carl.modifiers[DamageType.ranged_weapon_attacks] assert ranged_resistance in carl.modifiers[DamageType.ranged_weapon_attacks]
assert carls_shield in carl.attuned_items assert carls_shield in carl.attuned_items
assert carl.equip(carl.equipment.get(helm)[0]) assert carl.equip(carl.equipment.get(helm))
assert carl.armor_class == 13 assert carl.armor_class == 13
assert carl.unattune(carls_shield) assert carl.unattune(carls_shield)