fix multiclassing

This commit is contained in:
evilchili 2024-02-23 10:45:38 -08:00
parent ba0e66f9af
commit 9757d3bee0
7 changed files with 149 additions and 24 deletions

View File

@ -211,3 +211,17 @@ table td {
padding: 0; padding: 0;
margin: 0; margin: 0;
} }
ul.multiclass {
display: inline;
list-style: none;
margin: 0;
padding: 0;
}
.multiclass li {
display: inline;
}
.multiclass label {
display: none;
}

View File

@ -15,12 +15,13 @@
<div><img id='portrait' /></div> <div><img id='portrait' /></div>
<div> <div>
{{ field('name') }} {{ field('name') }}
{{ field('ancestry') }} {{ field('ancestry_id') }}
{% for rec in c.record.classes %} {% for obj in c.form['classes'] %}
{{ rec.character_class.name }} {{ rec.level }} {{ obj(class='multiclass') }}
{% endfor %} {% endfor %}
{{ c.form['newclass'](class='multiclass') }}
{{ c.record.character_class|join(' / ') }} &nbsp;
<div id='controls'> <div id='controls'>
{{ c.form.save }} &nbsp; {{ c.form.delete }} {{ c.form.save }} &nbsp; {{ c.form.delete }}
</div> </div>

View File

@ -1,3 +1,6 @@
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
@ -7,7 +10,9 @@ from sqlalchemy import Integer
from sqlalchemy import String from sqlalchemy import String
from sqlalchemy import Text from sqlalchemy import Text
from sqlalchemy import ForeignKey from sqlalchemy import ForeignKey
from sqlalchemy import UniqueConstraint
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.ext.associationproxy import association_proxy
__all__ = [ __all__ = [
@ -19,6 +24,11 @@ __all__ = [
'Character', 'Character',
] ]
def class_map_creator(fields):
if isinstance(fields, CharacterClassMap):
return fields
return CharacterClassMap(**fields)
class AncestryTraitMap(BaseObject): class AncestryTraitMap(BaseObject):
__tablename__ = "trait_map" __tablename__ = "trait_map"
@ -54,8 +64,11 @@ class AncestryTrait(BaseObject, IterableMixin):
class CharacterClassMap(BaseObject): class CharacterClassMap(BaseObject):
__tablename__ = "class_map" __tablename__ = "class_map"
character_id = Column(Integer, ForeignKey("character.id"), primary_key=True) id = Column(Integer, primary_key=True, autoincrement=True)
character_class_id = Column(Integer, ForeignKey("character_class.id"), primary_key=True) character_id = Column(Integer, ForeignKey("character.id"))
character_class_id = Column(Integer, ForeignKey("character_class.id"))
mapping = UniqueConstraint(character_id, character_class_id)
character_class = relationship("CharacterClass", lazy='immediate') character_class = relationship("CharacterClass", 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)
@ -84,7 +97,9 @@ class Character(*Bases, SavingThrowsMixin, SkillsMixin):
cha = Column(Integer, nullable=False, default=10, info={'min': 0, 'max': 30}) cha = Column(Integer, nullable=False, default=10, info={'min': 0, 'max': 30})
proficiencies = Column(String) proficiencies = Column(String)
classes = relationship("CharacterClassMap") class_map = relationship("CharacterClassMap", cascade='all,delete,delete-orphan')
classes = association_proxy('class_map', 'id', creator=class_map_creator)
attributes = relationship("CharacterClassAttributeMap") attributes = relationship("CharacterClassAttributeMap")
ancestry_id = Column(Integer, ForeignKey("ancestry.id"), nullable=False, default='1') ancestry_id = Column(Integer, ForeignKey("ancestry.id"), nullable=False, default='1')

View File

@ -6,6 +6,7 @@ from ttfrog.db.schema import TransactionLog
def record(previous, new): def record(previous, new):
logging.debug(f"{previous = }, {new = }")
diff = list((set(previous.items()) ^ set(dict(new).items()))) diff = list((set(previous.items()) ^ set(dict(new).items())))
if not diff: if not diff:
return return

View File

@ -71,6 +71,7 @@ class BaseController:
else: else:
self._form = self.model_form(obj=self.record) self._form = self.model_form(obj=self.record)
if not self.record.id: if not self.record.id:
# apply the db schema defaults
self._form.process() self._form.process()
return self._form return self._form
@ -96,14 +97,19 @@ class BaseController:
**kwargs, **kwargs,
) )
def populate(self):
self.form.populate_obj(self.record)
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.form.validate():
return return
previous = dict(self.record) logging.debug(f"{self.form.data = }")
self.form.populate_obj(self.record) # previous = dict(self.record)
transaction_log.record(previous, self.record) logging.debug(f"{self.record = }")
self.populate()
# 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 = }") logging.debug(f"Added {self.record = }")

View File

@ -1,11 +1,39 @@
import logging
from ttfrog.webserver.controllers.base import BaseController from ttfrog.webserver.controllers.base import BaseController
from ttfrog.webserver.forms import DeferredSelectField, DeferredSelectMultipleField from ttfrog.webserver.forms import DeferredSelectField
from ttfrog.db.schema import Character, Ancestry, CharacterClass from ttfrog.webserver.forms import NullableDeferredSelectField
from ttfrog.db.schema import Character, Ancestry, CharacterClass, CharacterClassMap
from ttfrog.db.base import STATS from ttfrog.db.base import STATS
from ttfrog.db.manager import db
from wtforms_alchemy import ModelForm from wtforms_alchemy import ModelForm
from wtforms.fields import SubmitField, SelectMultipleField from wtforms.fields import SubmitField, SelectField, SelectMultipleField, FieldList, FormField, HiddenField
from wtforms.widgets import Select from wtforms.widgets import Select, ListWidget
from wtforms import ValidationError
VALID_LEVELS = range(1, 21)
class MulticlassForm(ModelForm):
id = HiddenField()
character_class_id = NullableDeferredSelectField(
'CharacterClass',
model=CharacterClass,
validate_choice=True,
widget=Select(),
coerce=int
)
level = SelectField(choices=VALID_LEVELS, default=1, coerce=int, validate_choice=True, widget=Select())
def __init__(self, formdata=None, obj=None, prefix=None):
"""
Populate the form field with a CharacterClassMap object by converting the object ID
to an instance. This will ensure that the rendered field is populated with the current
value of the class_map.
"""
obj = db.query(CharacterClassMap).get(obj)
super().__init__(formdata=formdata, obj=obj, prefix=prefix)
class CharacterForm(ModelForm): class CharacterForm(ModelForm):
@ -15,16 +43,9 @@ class CharacterForm(ModelForm):
save = SubmitField() save = SubmitField()
delete = SubmitField() delete = SubmitField()
ancestry_id = DeferredSelectField('Ancestry', model=Ancestry, default=1, validate_choice=True, widget=Select())
ancestry = DeferredSelectField('Ancestry', model=Ancestry, default=1, validate_choice=True, widget=Select()) classes = FieldList(FormField(MulticlassForm, widget=ListWidget()), min_entries=0)
newclass = FormField(MulticlassForm, widget=ListWidget())
character_class = DeferredSelectMultipleField(
'CharacterClass',
model=CharacterClass,
validate_choice=True,
# option_widget=Select(multiple=True)
)
saving_throws = SelectMultipleField('Saving Throws', validate_choice=True, choices=STATS) saving_throws = SelectMultipleField('Saving Throws', validate_choice=True, choices=STATS)
@ -37,3 +58,63 @@ class CharacterSheet(BaseController):
return super().resources + [ return super().resources + [
{'type': 'script', 'uri': 'js/character_sheet.js'}, {'type': 'script', 'uri': 'js/character_sheet.js'},
] ]
def populate_class_map(self, formdata):
"""
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.
"""
err = ""
total_level = 0
for field in self.form.data['classes']:
level = field.get('level', 0)
total_level += level
if level not in VALID_LEVELS:
err = f"Multiclass form field {field = } level is outside possible range."
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:
err = f"Total level for all multiclasses ({total_level}) is outside possible range."
if err:
logging.error(err)
raise ValidationError(err)
def validate(self):
"""
Add custom validation of the multiclass form data to standard form validation.
"""
super().validate()
self.validate_multiclass_form()
def populate(self):
"""
Delete the multiclass form data before calling form.populate_obj() and use
our own method for populating the fieldlist.
"""
formdata = self.form.data['classes']
formdata.append(self.form.data['newclass'])
del self.form.classes
del self.form.newclass
super().populate()
self.populate_class_map(formdata)

View File

@ -1,6 +1,7 @@
from ttfrog.db.manager import db from ttfrog.db.manager import db
from wtforms.fields import SelectField, SelectMultipleField from wtforms.fields import SelectField, SelectMultipleField
class DeferredSelectMultipleField(SelectMultipleField): class DeferredSelectMultipleField(SelectMultipleField):
def __init__(self, *args, model=None, **kwargs): def __init__(self, *args, model=None, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -11,3 +12,9 @@ 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, rec.name) for rec in db.query(model).all()]
class NullableDeferredSelectField(DeferredSelectField):
def __init__(self, *args, model=None, label='---', **kwargs):
super().__init__(*args, model=model, **kwargs)
self.choices = [(0, label)] + self.choices