15
\$\begingroup\$

Goal

Design a representation of a mage character from the World of Darkness RPG, as well their associated spells.

Here is a visual representation of the schema. You can see it more closely on LucidChart if you like.

Lucid schema

You can also see my draft here and on YUML

I have already made an attempt at laying out the models, but my main concern is that I'm either:

a) over complicating the above design;
b) implementing what I want incorrectly;
c) both.

The contents of my models.py, and mages models.py are below:

#Main
from django.db import models
from nwod_characters.util import IntegerRangeField
from .choices import ATTRIBUTE_CHOICES, SKILL_CHOICES
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
# Create your models here.
class NWODCharacter(models.Model):
 class Meta:
 abstract = True
 SUB_RACE_CHOICES = ()
 FACTION_CHOICES = ()
 name = models.CharField(max_length=200)
 player = models.ForeignKey('auth.User')
 created_date = models.DateTimeField(auto_now_add=True, auto_now=False)
 updated_date = models.DateTimeField(auto_now_add=False, auto_now=True)
 published_date = models.DateTimeField(blank=True, null=True)
 sub_race = models.CharField(choices=SUB_RACE_CHOICES, max_length=50)
 faction = models.CharField(choices=FACTION_CHOICES, max_length=50, null=True)
class Characteristics(models.Model):
 class Meta:
 abstract = True
 VIRTUE_CHOICES = (('prudence', 'Prudence'), ('justice', 'Justice'),
 ('temperance', 'Temperance'), ('fortitude', 'Fortitude'), ('faith', 'Faith'), 
 ('hope', 'Hope'), ('charity', 'Charity'))
 VICE_CHOICES = (('lust', 'Lust'), ('gluttony', 'Gluttony'), ('greed', 'Greed'),
 ('sloth', 'Sloth'), ('wrath', 'Wrath'), ('envy', 'Envy'), ('pride', 'Pride'))
 power_level = IntegerRangeField(min_value=1, max_value=10)
 energy_trait = IntegerRangeField(min_value=1, max_value=10)
 virtue = models.CharField(choices=VIRTUE_CHOICES, max_length=50)
 vice = models.CharField(choices=VICE_CHOICES, max_length=50)
 morality = IntegerRangeField(min_value=0, max_value=10)
 size = IntegerRangeField(min_value=1, max_value=10, default=5)
def resistance_attributes():
 res = [ATTRIBUTE_CHOICES[i][-1][-1] for i in range(len(ATTRIBUTE_CHOICES))]
 return res
class Trait(models.Model):
 MIN = 0
 MAX = 5
 current_value = IntegerRangeField(min_value=MIN, max_value=MAX)
 maximum_value = IntegerRangeField(min_value=MIN, max_value=MAX)
 class Meta:
 abstract = True
class BookReference(models.Model):
 things_in_books = models.Q(app_label='mage', model='spell') | models.Q(app_label='characters', model='merit')
 content_type = models.ForeignKey(ContentType, limit_choices_to=things_in_books,
 null=True, blank=True)
 object_id = models.PositiveIntegerField(null=True)
 content_object = GenericForeignKey('content_type', 'object_id') 
 book_name = models.CharField(max_length=50)
 book_page = models.PositiveSmallIntegerField(default=0)
class Merit(Trait, models.Model):
 name = models.CharField(max_length=50)
 book_ref = models.ForeignKey('BookReference')
class Skill(models.Model):
 name = models.CharField(max_length=50, choices=SKILL_CHOICES)
class Attribute(models.Model):
 name = models.CharField(max_length=50, choices=ATTRIBUTE_CHOICES)
class CrossCharacterMixin(models.Model):
 cross_character_types = models.Q(app_label='mage', model='mage')
 content_type = models.ForeignKey(ContentType, limit_choices_to=cross_character_types,
 null=True, blank=True)
 object_id = models.PositiveIntegerField(null=True)
 content_object = GenericForeignKey('content_type', 'object_id')
 class Meta:
 abstract = True
class SkillLink(models.Model):
 skill = models.ForeignKey('Skill', choices=SKILL_CHOICES)
 class Meta:
 abstract = True
class AttributeLink(models.Model):
 attribute = models.ForeignKey('Attribute', choices=ATTRIBUTE_CHOICES)
 class Meta:
 abstract = True 
class CharacterSkillLink(SkillLink, Trait, CrossCharacterMixin):
 PRIORITY_CHOICES = (
 (1, 'Primary'), (2, 'Secondary'), (3, 'Tertiary')
 )
 priority = models.PositiveSmallIntegerField(choices=PRIORITY_CHOICES, default=None)
 speciality = models.CharField(max_length=200)
class CharacterAttributeLink(AttributeLink, Trait, CrossCharacterMixin):
 MIN = 1
 PRIORITY_CHOICES = (
 (1, 'Primary'), (2, 'Secondary'), (3, 'Tertiary')
 )
 priority = models.PositiveSmallIntegerField(choices=PRIORITY_CHOICES, default=None)
#mages/models.py
from django.db import models
from characters.models import NWODCharacter, Characteristics, Trait, AttributeLink, SkillLink
from characters.choices import ATTRIBUTE_CHOICES, SKILL_CHOICES
from nwod_characters.util import modify_verbose, IntegerRangeField
from django.contrib.contenttypes.fields import GenericRelation
@modify_verbose({'power_level': 'Gnosis',
 'energy_trait': 'Mana',
 'faction': 'Order',
 'sub_race': 'Path',
 'morality': 'Wisdom',
 })
class Mage(NWODCharacter, Characteristics):
 SUB_RACE_CHOICES = (
 ('AC', 'Acanthus'),
 ('Ma', 'Mastigos'),
 ('Mo', 'Moros'),
 ('Ob', 'Obrimos'),
 ('Th', 'Thyrsus'),
 )
 FACTION_CHOICES = (
 ('AA', 'The Adamantine Arrow'),
 ('GotV', 'Guardians of the Veil'),
 ('Myst', 'The Mysterium'),
 ('SL', 'The Silver Ladder'),
 ('FC', 'The Free Council')
 )
 def __str__(self):
 return self.name
ARCANUM_CHOICES = (
 (None, '----'), ('Fate', 'Fate'), ('Mind', 'Mind'), ('Spirit', 'Spirit'), ('Death', 'Death'),
 ('Forces', 'Forces'), ('Time', 'Time'), ('Space', 'Space'), ('Life', 'Life'), ('Matter', 'Matter'),
 ('Prime', 'Prime')
 )
class Spell(models.Model):
 name = models.CharField(max_length=50)
 vulgar = models.BooleanField(default=False)
 # All spells have a primary arcana, and 0-Many secondary arcana.
 # Each spell's arcanum have different rating. E.g. Fate 1, Prime 1
 #non-optional arcana in addition to the main arcana
 # arcana that are not needed to cast the spell
 arcana = models.ManyToManyField('Arcana', choices=ARCANUM_CHOICES, through='SpellArcanumLink', 
 related_name='spell_by_arcanum')
 @property
 def primary_arcana(self):
 return self.arcana.filter(type="primary")
 @property
 def secondary_arcana(self):
 return self.arcana.filter(type="secondary")
 @property
 def optional_arcana(self):
 return self.arcana.filter(type="optional")
 # All spells have a 'Attribute+Skill+Primary arcana' pool for casting
 skill = models.ManyToManyField('Skill', choices=SKILL_CHOICES, related_name='spell_by_skill',
 through='SpellSkillLink')
 attribute = models.ManyToManyField('Attribute', choices=ATTRIBUTE_CHOICES, 
 related_name='spell_by_attribute', through='SpellAttributeLink')
 @property
 def rote_skill(self):
 return self.skill.filter(type="rote") if self.contested else None
 @property
 def rote_attribute(self):
 return self.attribute.filter(type="rote") if self.contested else None
 # Mages can own spells
 mage = models.ManyToManyField('Mage', related_name='spell_by_mage', through='SpellMageLink')
 # Optional contested skill check, e.g. 'Attribute+Skill+Primary arcana vs Attribute+Skill'
 contested = models.BooleanField(default=False)
 @property
 def contested_attribute(self):
 return self.attribute.filter(type="contested") if self.contested else None
 @property
 def contested_skill(self):
 return self.skill.filter(type="contested") if self.contested else None
 # Optional attribute to subtract from casting dicepool, 
 # e.g. 'Attribute+Skill+Primary arcana-Resist Attribute'
 resisted = models.BooleanField(default=False)
 @property
 def resisted_attribute(self):
 return self.attribute.filter(type="resisted") if self.resisted else None
 # Spells come from books
 book_ref = GenericRelation('BookReference', null=True, blank=True)
class ArcanaLink(models.Model):
 arcana = models.ForeignKey('Arcana', choices=ARCANUM_CHOICES)
 class Meta:
 abstract = True
class Arcana(models.Model):
 name = models.CharField(max_length=50, choices=ARCANUM_CHOICES)
class CharacterArcanumLink(ArcanaLink, Trait):
 PRIORITY_CHOICES = (
 (1, 'Ruling'), (2, 'Common'), (3, 'Inferior')
 )
 priority = models.PositiveSmallIntegerField(choices=PRIORITY_CHOICES, default=None)
 mage = models.ForeignKey('Mage')
class SpellLink(models.Model):
 spell = models.ForeignKey('Spell')
 class Meta:
 abstract = True
class SpellArcanumLink(ArcanaLink, SpellLink):
 type = models.CharField(max_length=32,
 choices=(('primary', 'primary'), ('secondary', 'secondary'), ('optional', 'optional'))
 )
 value = IntegerRangeField(min_value=1, max_value=10)
class SpellAttributeLink(SpellLink, AttributeLink):
 type = models.CharField(max_length=32, 
 choices=(('resisted', 'resisted'), ('contested', 'contested'), ('rote', 'rote'))
 )
class SpellSkillLink(SpellLink, SkillLink):
 type = models.CharField(max_length=32,
 choices=(('contested', 'contested'), ('rote', 'rote')), default='Rote'
 )
class SpellMageLink(SpellLink):
 mage = models.ForeignKey('Mage') 

Below are the ancillary files (utils, and choices):

SKILL_CHOICES = (
 ('Mental', (
 ('Academics', 'Academics'),
 ('Computer', 'Computer'),
 ('Crafts', 'Crafts'),
 ('Investigation', 'Investigation'),
 ('Medicine', 'Medicine'),
 ('Occult', 'Occult'),
 ('Politics', 'Politics'),
 ('Science', 'Science'),
 )
 ),
 ('Physical', (
 ('Athletics', 'Athletics'),
 ('Brawl', 'Brawl'),
 ('Drive', 'Drive'),
 ('Firearms', 'Firearms'),
 ('Larceny', 'Larceny'),
 ('Stealth', 'Stealth'),
 ('Survival', 'Survival'),
 ('Weaponry', 'Weaponry'),
 )
 ),
 ('Social', (
 ('Animal Ken', 'Animal Ken'),
 ('Empathy', 'Empathy'),
 ('Expression', 'Expression'),
 ('Intimidation', 'Intimidation'),
 ('Persuasion', 'Persuasion'),
 ('Socialize', 'Socialize'),
 ('Streetwise', 'Streetwise'),
 ('Subterfuge', 'Subterfuge'),
 )
 )
)
ATTRIBUTE_CHOICES = (
 ('Mental', (
 ('Intelligence', 'Intelligence'),
 ('Wits', 'Wits'),
 ('Resolve', 'Resolve'),
 )
 ),
 ('Physical', (
 ('Strength', 'Strength'),
 ('Dexterity', 'Dexterity'),
 ('Stamina', 'Stamina'),
 )
 ),
 ('Social', (
 ('Presence', 'Presence'),
 ('Manipulation', 'Manipulation'),
 ('Composure', 'Composure'),
 )
 )
)
class IntegerRangeField(models.IntegerField):
 def __init__(self, verbose_name=None, name=None, min_value=None, max_value=None, **kwargs):
 self.min_value, self.max_value = min_value, max_value
 models.IntegerField.__init__(self, verbose_name, name, **kwargs)
 def formfield(self, **kwargs):
 defaults = {'min_value': self.min_value, 'max_value':self.max_value}
 defaults.update(kwargs)
 return super(IntegerRangeField, self).formfield(**defaults)
def modify_verbose(name_dict):
 def wrap(cls):
 for field, val in list(name_dict.items()):
 setattr(cls._meta.get_field(field), 'verbose_name', val)
 return cls
 return wrap

And, as I'm intended to use django-rest-framework I'm just using the admin page for inputting data, so here's my admin.py:

from django.contrib import admin
from django.contrib.contenttypes import admin as genericAdmin
from .models import Skill, Attribute, CharacterAttributeLink, CharacterSkillLink, BookReference
from .mage.models import Mage, Spell, Arcana, CharacterArcanumLink, SpellArcanumLink, SpellAttributeLink, SpellSkillLink
class AttributeInline(genericAdmin.GenericTabularInline):
 model = CharacterAttributeLink
 extra = 9
 max_num = 9
 can_delete = False
class SkillInline(genericAdmin.GenericTabularInline):
 model = CharacterSkillLink
 extra = 24
 max_num = 24
 can_delete = False
class ExtraAttributeInline(admin.StackedInline):
 model = SpellAttributeLink
 extra = 1
 can_delete = False
class ExtraSkillInline(admin.StackedInline):
 model = SpellSkillLink
 extra = 1
 can_delete = False
class ArcanaInline(admin.TabularInline):
 model = CharacterArcanumLink
class ExtraSpellArcanaInline(admin.TabularInline):
 model = SpellArcanumLink
 extra = 1
class BookReferenceInline(genericAdmin.GenericStackedInline):
 model = BookReference
 max_num = 1
class SpellInline(admin.TabularInline):
 model = Spell.mage.through 
class MageAdmin(admin.ModelAdmin):
 inlines = [AttributeInline, SkillInline, ArcanaInline, SpellInline]
class SpellAdmin(admin.ModelAdmin):
 inlines = [ExtraAttributeInline, ExtraSkillInline, ExtraSpellArcanaInline, BookReferenceInline]
# Register your models here.
admin.site.register(Mage, MageAdmin)
admin.site.register(Skill)
admin.site.register(Attribute)
admin.site.register(Arcana)
admin.site.register(Spell, SpellAdmin)

Key points I want to check:

  • Based on my comments in my models.py and the diagram, have I created the right links between the models?
  • Is this a good design for what I'm trying to achieve? I want to have many characters (some mages), mages have spells, spells have various Dice Pools made of combinations of traits (Attributes, Skills, Arcana, etcé) on them.
  • Am I writing Python correctly and idiomatically?

Please let me know if anything doesn't make sense. I'm more than happy to clarify if you can tell me what doesn't make sense.

Here is the source-code on github, if it helps at all.


My main concern is the general design pattern I've employed with the linking tables. The code has moved on since then, but the shape of my tables is the same.

asked Jan 29, 2015 at 14:28
\$\endgroup\$
1
  • \$\begingroup\$ If the code here doesn't work, then the question will have to be closed. \$\endgroup\$ Commented Feb 5, 2015 at 17:18

1 Answer 1

2
+100
\$\begingroup\$

You migrated the SKILL_CHOICES and ATTRIBUTE_CHOICES out to a separate file. Why not do the same for VIRTUE_CHOICES, VICE_CHOICES, SUB_RACE_CHOICES, FACTION_CHOICES, ARCANUM_CHOICES, PRIORITY_CHOICES? How are the other choices special?

I don't really see how I can review the rest of the code, though. What you have here is largely a data model... and without seeing code that will use these objects or the domain knowledge what this data model represents, I can't tell you whether your data model is good.

... I wonder, though... do you really need to turn all your links into classes? Would a table structure really be so bad? You can have a class that manages that specific table... I imagine that you'd use some of the collections classes that your language provides.

answered Feb 12, 2015 at 16:01
\$\endgroup\$
1
  • \$\begingroup\$ You're right, they aren't. That would be good code clean up. \$\endgroup\$ Commented Feb 12, 2015 at 16:03

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.