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.
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.