adding support for managing class attributes

This commit is contained in:
evilchili 2024-02-26 01:12:45 -08:00
parent 75b9aec28e
commit 304a4d9c79
7 changed files with 175 additions and 76 deletions

View File

@ -143,11 +143,9 @@
</div> </div>
<div class='card'> <div class='card'>
<div class='label'>Attributes</div> <div class='label'>Attributes</div>
<ul> {% if c.record.class_attributes %}
{% for rec in c.record.attributes %} {{ field('class_attributes') }}
<li>{{ rec.attribute.name }}: {{ rec.attribute.value }}</li> {% endif %}
{% endfor %}
</ul>
</div> </div>
<div class='card'> <div class='card'>
<div class='label'>Defenses</div> <div class='label'>Defenses</div>
@ -171,18 +169,17 @@
<div style='clear:both;display:block;'> <div style='clear:both;display:block;'>
<h2>Debug</h2> <h2>Debug</h2>
<code> <code>
{{ DISABLED }} {% for field, msg in c.form.errors.items() %}
<code> {{ field }}: {{ msg }}
{{ c.record }} {% endfor %}
</code>
{{ c.record.class_attributes }}
</code> </code>
{% endblock %} {% endblock %}
{% block script %} {% block script %}
<script type='text/javascript'> <script type='text/javascript'>
{% for field, msg in c.form.errors.items() %}
console.log("{{ field }}: {{ msg }}");
{% endfor %}
const TRAITS = { const TRAITS = {
{% for trait_desc, traits in [] %} {% for trait_desc, traits in [] %}
'{{ trait_desc }}': [ '{{ trait_desc }}': [

View File

@ -53,16 +53,16 @@ data = {
], ],
'AncestryTrait': [ 'AncestryTrait': [
{ 'id': 1, 'name': '+1 to All Ability Scores', }, {'id': 1, 'name': '+1 to All Ability Scores', },
{ 'id': 2, 'name': 'Breath Weapon', }, {'id': 2, 'name': 'Breath Weapon', },
{ 'id': 3, 'name': 'Darkvision', }, {'id': 3, 'name': 'Darkvision', },
], ],
'AncestryTraitMap': [ 'AncestryTraitMap': [
{ 'ancestry_id': 1, 'ancestry_trait_id': 1, 'level': 1}, # human +1 to scores {'ancestry_id': 1, 'ancestry_trait_id': 1, 'level': 1}, # human +1 to scores
{ 'ancestry_id': 2, 'ancestry_trait_id': 2, 'level': 1}, # dragonborn breath weapon {'ancestry_id': 2, 'ancestry_trait_id': 2, 'level': 1}, # dragonborn breath weapon
{ 'ancestry_id': 3, 'ancestry_trait_id': 3, 'level': 1}, # tiefling darkvision {'ancestry_id': 3, 'ancestry_trait_id': 3, 'level': 1}, # tiefling darkvision
{ 'ancestry_id': 2, 'ancestry_trait_id': 2, 'level': 1}, # elf darkvision {'ancestry_id': 2, 'ancestry_trait_id': 2, 'level': 1}, # elf darkvision
], ],
'CharacterClassMap': [ 'CharacterClassMap': [
@ -101,15 +101,26 @@ data = {
], ],
'ClassAttribute': [ 'ClassAttribute': [
{'id': 1, 'name': 'Fighting Style', 'value': 'Archery'}, {'id': 1, 'name': 'Fighting Style'},
{'id': 2, 'name': 'Another Attribute'},
], ],
'ClassAttributeOption': [
{'id': 1, 'attribute_id': 1, 'name': 'Archery'},
{'id': 2, 'attribute_id': 1, 'name': 'Battlemaster'},
{'id': 3, 'attribute_id': 2, 'name': 'Another Option 1'},
{'id': 4, 'attribute_id': 2, 'name': 'Another Option 2'},
],
'ClassAttributeMap': [ 'ClassAttributeMap': [
{'class_attribute_id': 1, 'character_class_id': 1, 'level': 2}, # Fighter: Archery fighting style {'class_attribute_id': 1, 'character_class_id': 1, 'level': 2}, # Fighter: Fighting Style
{'class_attribute_id': 2, 'character_class_id': 1, 'level': 1}, # Fighter: Another Attr
], ],
'CharacterClassAttributeMap': [ 'CharacterClassAttributeMap': [
{'class_attribute_id': 1, 'character_id': 1}, # Sabetha: Archery fighting style {'character_id': 1, 'class_attribute_id': 2, 'option_id': 4}, # Sabetha, another option, option 2
{'character_id': 1, 'class_attribute_id': 1, 'option_id': 1}, # Sabetha, fighting style, archery
], ],

View File

@ -1,6 +1,3 @@
import logging
from ttfrog.db.base import Bases, BaseObject, IterableMixin, SavingThrowsMixin, SkillsMixin from ttfrog.db.base import Bases, BaseObject, IterableMixin, SavingThrowsMixin, SkillsMixin
from ttfrog.db.base import CreatureTypesEnum from ttfrog.db.base import CreatureTypesEnum
@ -24,11 +21,17 @@ __all__ = [
'Character', 'Character',
] ]
def class_map_creator(fields): def class_map_creator(fields):
if isinstance(fields, CharacterClassMap): if isinstance(fields, CharacterClassMap):
return fields return fields
return CharacterClassMap(**fields) return CharacterClassMap(**fields)
def attr_map_creator(fields):
if isinstance(fields, CharacterClassAttributeMap):
return fields
return CharacterClassAttributeMap(**fields)
class AncestryTraitMap(BaseObject): class AncestryTraitMap(BaseObject):
__tablename__ = "trait_map" __tablename__ = "trait_map"
@ -62,7 +65,7 @@ class AncestryTrait(BaseObject, IterableMixin):
description = Column(Text) description = Column(Text)
class CharacterClassMap(BaseObject): class CharacterClassMap(BaseObject, IterableMixin):
__tablename__ = "class_map" __tablename__ = "class_map"
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
character_id = Column(Integer, ForeignKey("character.id")) character_id = Column(Integer, ForeignKey("character.id"))
@ -73,11 +76,16 @@ class CharacterClassMap(BaseObject):
level = Column(Integer, nullable=False, info={'min': 1, 'max': 20}, default=1) level = Column(Integer, nullable=False, info={'min': 1, 'max': 20}, default=1)
class CharacterClassAttributeMap(BaseObject): class CharacterClassAttributeMap(BaseObject, IterableMixin):
__tablename__ = "character_class_attribute_map" __tablename__ = "character_class_attribute_map"
class_attribute_id = Column(Integer, ForeignKey("class_attribute.id"), primary_key=True) id = Column(Integer, primary_key=True, autoincrement=True)
character_id = Column(Integer, ForeignKey("character.id"), primary_key=True) character_id = Column(Integer, ForeignKey("character.id"), nullable=False)
attribute = relationship("ClassAttribute", lazy='immediate') class_attribute_id = Column(Integer, ForeignKey("class_attribute.id"), nullable=False)
option_id = Column(Integer, ForeignKey("class_attribute_option.id"), nullable=False)
mapping = UniqueConstraint(character_id, class_attribute_id)
class_attribute = relationship("ClassAttribute", lazy='immediate')
option = relationship("ClassAttributeOption", lazy='immediate')
class Character(*Bases, SavingThrowsMixin, SkillsMixin): class Character(*Bases, SavingThrowsMixin, SkillsMixin):
@ -100,7 +108,8 @@ class Character(*Bases, SavingThrowsMixin, SkillsMixin):
class_map = relationship("CharacterClassMap", cascade='all,delete,delete-orphan') class_map = relationship("CharacterClassMap", cascade='all,delete,delete-orphan')
classes = association_proxy('class_map', 'id', creator=class_map_creator) classes = association_proxy('class_map', 'id', creator=class_map_creator)
attributes = relationship("CharacterClassAttributeMap") character_class_attribute_map = relationship("CharacterClassAttributeMap", cascade='all,delete,delete-orphan')
class_attributes = association_proxy('character_class_attribute_map', 'id', creator=attr_map_creator)
ancestry_id = Column(Integer, ForeignKey("ancestry.id"), nullable=False, default='1') ancestry_id = Column(Integer, ForeignKey("ancestry.id"), nullable=False, default='1')
ancestry = relationship("Ancestry", uselist=False) ancestry = relationship("Ancestry", uselist=False)

View File

@ -13,15 +13,15 @@ from sqlalchemy.orm import relationship
__all__ = [ __all__ = [
'ClassAttributeMap', 'ClassAttributeMap',
'ClassAttribute', 'ClassAttribute',
'ClassAttributeOption',
'CharacterClass', 'CharacterClass',
] ]
class ClassAttributeMap(BaseObject): class ClassAttributeMap(BaseObject, IterableMixin):
__tablename__ = "class_attribute_map" __tablename__ = "class_attribute_map"
class_attribute_id = Column(Integer, ForeignKey("class_attribute.id"), primary_key=True) class_attribute_id = Column(Integer, ForeignKey("class_attribute.id"), primary_key=True)
character_class_id = Column(Integer, ForeignKey("character_class.id"), primary_key=True) character_class_id = Column(Integer, ForeignKey("character_class.id"), primary_key=True)
attribute = relationship("ClassAttribute", lazy='immediate')
level = Column(Integer, nullable=False, info={'min': 1, 'max': 20}, default=1) level = Column(Integer, nullable=False, info={'min': 1, 'max': 20}, default=1)
@ -29,8 +29,17 @@ class ClassAttribute(BaseObject, IterableMixin):
__tablename__ = "class_attribute" __tablename__ = "class_attribute"
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String, nullable=False) name = Column(String, nullable=False)
value = Column(String, nullable=False)
description = Column(Text) def __repr__(self):
return f"{self.id}: {self.name}"
class ClassAttributeOption(BaseObject, IterableMixin):
__tablename__ = "class_attribute_option"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String, nullable=False)
attribute_id = Column(Integer, ForeignKey("class_attribute.id"), nullable=False)
# attribute = relationship("ClassAttribute", uselist=False)
class CharacterClass(*Bases, SavingThrowsMixin, SkillsMixin): class CharacterClass(*Bases, SavingThrowsMixin, SkillsMixin):

View File

@ -100,10 +100,27 @@ class BaseController:
def populate(self): def populate(self):
self.form.populate_obj(self.record) self.form.populate_obj(self.record)
def populate_association(self, key, formdata):
populated = []
for field in formdata:
map_id = field.pop('id')
map_id = int(map_id) if map_id else 0
if not field[key]:
continue
elif not map_id:
populated.append(field)
else:
field['id'] = map_id
populated.append(field)
return populated
def validate(self):
return self.form.validate()
def save(self): def save(self):
if not self.form.save.data: if not self.form.save.data:
return return
if not self.form.validate(): if not self.validate():
return return
logging.debug(f"{self.form.data = }") logging.debug(f"{self.form.data = }")
# previous = dict(self.record) # previous = dict(self.record)
@ -112,7 +129,8 @@ class BaseController:
# transaction_log.record(previous, self.record) # transaction_log.record(previous, self.record)
with db.transaction(): with db.transaction():
db.add(self.record) db.add(self.record)
logging.debug(f"Added {self.record = }") self.save_callback()
logging.debug(f"Saved {self.record = }")
location = self.request.current_route_path() location = self.request.current_route_path()
if self.record.slug not in location: if self.record.slug not in location:
location = f"{location}/{self.record.uri}" location = f"{location}/{self.record.uri}"

View File

@ -3,7 +3,15 @@ import logging
from ttfrog.webserver.controllers.base import BaseController from ttfrog.webserver.controllers.base import BaseController
from ttfrog.webserver.forms import DeferredSelectField from ttfrog.webserver.forms import DeferredSelectField
from ttfrog.webserver.forms import NullableDeferredSelectField from ttfrog.webserver.forms import NullableDeferredSelectField
from ttfrog.db.schema import Character, Ancestry, CharacterClass, CharacterClassMap from ttfrog.db.schema import (
Character,
Ancestry,
CharacterClass,
CharacterClassMap,
ClassAttributeOption,
CharacterClassAttributeMap
)
from ttfrog.db.base import STATS from ttfrog.db.base import STATS
from ttfrog.db.manager import db from ttfrog.db.manager import db
@ -11,14 +19,37 @@ from wtforms_alchemy import ModelForm
from wtforms.fields import SubmitField, SelectField, SelectMultipleField, FieldList, FormField, HiddenField from wtforms.fields import SubmitField, SelectField, SelectMultipleField, FieldList, FormField, HiddenField
from wtforms.widgets import Select, ListWidget from wtforms.widgets import Select, ListWidget
from wtforms import ValidationError from wtforms import ValidationError
from wtforms.validators import Optional
VALID_LEVELS = range(1, 21) VALID_LEVELS = range(1, 21)
class ClassAttributesForm(ModelForm):
id = HiddenField()
class_attribute_id = HiddenField()
option_id = SelectField(
widget=Select(),
choices=[],
validators=[Optional()],
coerce=int
)
def __init__(self, formdata=None, obj=None, prefix=None):
if obj:
obj = db.query(CharacterClassAttributeMap).get(obj)
super().__init__(formdata=formdata, obj=obj, prefix=prefix)
if obj:
options = db.query(ClassAttributeOption).filter_by(attribute_id=obj.class_attribute.id)
self.option_id.label = obj.class_attribute.name
self.option_id.choices = [(rec.id, rec.name) for rec in options.all()]
class MulticlassForm(ModelForm): class MulticlassForm(ModelForm):
id = HiddenField() id = HiddenField()
character_class_id = NullableDeferredSelectField( character_class_id = NullableDeferredSelectField(
'CharacterClass',
model=CharacterClass, model=CharacterClass,
validate_choice=True, validate_choice=True,
widget=Select(), widget=Select(),
@ -32,6 +63,7 @@ class MulticlassForm(ModelForm):
to an instance. This will ensure that the rendered field is populated with the current to an instance. This will ensure that the rendered field is populated with the current
value of the class_map. value of the class_map.
""" """
if obj:
obj = db.query(CharacterClassMap).get(obj) obj = db.query(CharacterClassMap).get(obj)
super().__init__(formdata=formdata, obj=obj, prefix=prefix) super().__init__(formdata=formdata, obj=obj, prefix=prefix)
@ -46,6 +78,9 @@ class CharacterForm(ModelForm):
ancestry_id = DeferredSelectField('Ancestry', model=Ancestry, default=1, validate_choice=True, widget=Select()) ancestry_id = DeferredSelectField('Ancestry', model=Ancestry, default=1, validate_choice=True, widget=Select())
classes = FieldList(FormField(MulticlassForm, widget=ListWidget()), min_entries=0) classes = FieldList(FormField(MulticlassForm, widget=ListWidget()), min_entries=0)
newclass = FormField(MulticlassForm, widget=ListWidget()) newclass = FormField(MulticlassForm, widget=ListWidget())
class_attributes = FieldList(FormField(ClassAttributesForm, widget=ListWidget()), min_entries=1)
saving_throws = SelectMultipleField('Saving Throws', validate_choice=True, choices=STATS) saving_throws = SelectMultipleField('Saving Throws', validate_choice=True, choices=STATS)
@ -59,62 +94,82 @@ class CharacterSheet(BaseController):
{'type': 'script', 'uri': 'js/character_sheet.js'}, {'type': 'script', 'uri': 'js/character_sheet.js'},
] ]
def populate_class_map(self, formdata): def validate_callback(self):
"""
Populate the record's class_map association_proxy with dictionaries of
CharacterClassMap field data. The creator factory on the proxy will
convert dictionary data to CharacterClassMap instances..
"""
populated = []
for field in formdata:
class_map_id = field.pop('id')
class_map_id = int(class_map_id) if class_map_id else 0
logging.debug(f"{class_map_id = }, {field = }, {self.record.classes = }")
if not field['character_class_id']:
continue
elif not class_map_id:
populated.append(field)
else:
field['id'] = class_map_id
populated.append(field)
self.record.classes = populated
def validate_multiclass_form(self):
""" """
Validate multiclass fields in form data. Validate multiclass fields in form data.
""" """
ret = super().validate()
if not self.form.data['classes']:
return ret
err = "" err = ""
total_level = 0 total_level = 0
for field in self.form.data['classes']: for field in self.form.data['classes']:
level = field.get('level', 0) level = field.get('level')
total_level += level total_level += level
if level not in VALID_LEVELS: if level not in VALID_LEVELS:
err = f"Multiclass form field {field = } level is outside possible range." err = f"Multiclass form field {field = } level is outside possible range."
break break
if self.record.id and field.get('character_id', None) != self.record.id:
err = f"Multiclass form field {field = } does not match character ID {self.record.id}"
break
if total_level not in VALID_LEVELS: if total_level not in VALID_LEVELS:
err = f"Total level for all multiclasses ({total_level}) is outside possible range." err = f"Total level for all multiclasses ({total_level}) is outside possible range."
if err: if err:
logging.error(err) logging.error(err)
raise ValidationError(err) raise ValidationError(err)
return ret and True
def validate(self): def add_class_attributes(self):
""" # prefetch the records for each of the character's classes
Add custom validation of the multiclass form data to standard form validation. classes_by_id = {
""" c.id: c for c in db.query(CharacterClass).filter(CharacterClass.id.in_(
super().validate() c.character_class_id for c in self.record.class_map
self.validate_multiclass_form() )).all()
}
assigned = [int(m.class_attribute_id) for m in self.record.character_class_attribute_map]
logging.debug(f"{assigned = }")
# step through the list of class mappings for this character
for class_map in self.record.class_map:
thisclass = classes_by_id[class_map.character_class_id]
# assign each class attribute available at the character's current
# level to the list of the character's class attributes
for attr_map in [a for a in thisclass.attributes if a.level <= class_map.level]:
# when creating a record, assign the first of the available
# options to the character's class attribute.
default_option = db.query(ClassAttributeOption).filter_by(
attribute_id=attr_map.class_attribute_id
).first()
if attr_map.class_attribute_id not in assigned:
self.record.character_class_attribute_map.append(
CharacterClassAttributeMap(
class_attribute_id=attr_map.class_attribute_id,
option=default_option
)
)
def save_callback(self):
self.add_class_attributes()
def populate(self): def populate(self):
""" """
Delete the multiclass form data before calling form.populate_obj() and use Delete the association proxies' form data before calling form.populate_obj(),
our own method for populating the fieldlist. and instead use our own methods for populating the fieldlist.
""" """
formdata = self.form.data['classes']
formdata.append(self.form.data['newclass']) # multiclass form
classes_formdata = self.form.data['classes']
classes_formdata.append(self.form.data['newclass'])
del self.form.classes del self.form.classes
del self.form.newclass del self.form.newclass
# class attributes
attrs_formdata = self.form.data['class_attributes']
del self.form.class_attributes
super().populate() super().populate()
self.populate_class_map(formdata)
self.record.classes = self.populate_association('character_class_id', classes_formdata)
self.record.class_attributes = self.populate_association('class_attribute_id', attrs_formdata)

View File

@ -11,7 +11,7 @@ class DeferredSelectMultipleField(SelectMultipleField):
class DeferredSelectField(SelectField): class DeferredSelectField(SelectField):
def __init__(self, *args, model=None, **kwargs): def __init__(self, *args, model=None, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.choices = [(rec.id, rec.name) for rec in db.query(model).all()] self.choices = [(rec.id, getattr(rec, 'name', str(rec))) for rec in db.query(model).all()]
class NullableDeferredSelectField(DeferredSelectField): class NullableDeferredSelectField(DeferredSelectField):