charasheet/src/character/models/character.py
2023-03-25 10:02:50 +01:00

480 lines
14 KiB
Python

import collections
from collections.abc import Iterable
from dataclasses import dataclass
from functools import partial
import markdown
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Q
from django.db.models.functions import Lower
from django.urls import reverse
from django_extensions.db.models import TimeStampedModel
from character.models import Capability, Path
from character.models.dice import Dice
from character.models.equipment import Weapon
from common.models import DocumentedModel, UniquelyNamedModel
class Profile( # noqa: DJ008
DocumentedModel,
UniquelyNamedModel,
TimeStampedModel,
models.Model,
):
class MagicalStrength(models.TextChoices):
NONE = "NON", "Aucun"
INTELLIGENCE = "INT", "Intelligence"
WISDOM = "SAG", "Sagesse"
CHARISMA = "CHA", "Charisme"
class ManaMax(models.IntegerChoices):
NO_MANA = 0, "Pas de mana"
LEVEL = 1, "1 x niveau + mod. magique"
DOUBLE_LEVEL = 2, "2 x niveau + mod. magique"
magical_strength = models.CharField(
max_length=3,
choices=MagicalStrength.choices,
default=MagicalStrength.NONE,
verbose_name="force magique",
)
life_dice = models.PositiveSmallIntegerField(
choices=Dice.choices,
verbose_name="dé de vie",
)
mana_max_compute = models.PositiveSmallIntegerField(
choices=ManaMax.choices,
verbose_name="calcul mana max",
default=ManaMax.NO_MANA,
)
notes = models.TextField(blank=True, verbose_name="notes")
class Meta(UniquelyNamedModel.Meta, TimeStampedModel.Meta):
verbose_name = "Profil"
verbose_name_plural = "Profils"
class Race( # noqa: DJ008
DocumentedModel,
UniquelyNamedModel,
TimeStampedModel,
models.Model,
):
class Meta(UniquelyNamedModel.Meta, TimeStampedModel.Meta):
verbose_name = "Race"
verbose_name_plural = "Races"
class HarmfulState( # noqa: DJ008
DocumentedModel,
UniquelyNamedModel,
TimeStampedModel,
models.Model,
):
description = models.TextField()
icon_url = models.URLField()
class Meta(UniquelyNamedModel.Meta, TimeStampedModel.Meta):
verbose_name = "État préjudiciable"
verbose_name_plural = "États préjudiciables"
def modifier(value: int) -> int:
if not value:
return 0
if 1 < value < 10: # noqa: PLR2004
value -= 1
value -= 10
return int(value / 2)
class CharacterManager(models.Manager):
def get_by_natural_key(self, name: str, player_id: int):
return self.get(name=name, player_id=player_id)
class CharacterQuerySet(models.QuerySet):
def managed_by(self, user):
"""
Return characters managed by the given user.
Characters are managed by a user if they own the character
or if they are the game master for a group in which the character plays.
"""
from party.models import Party
return self.filter(
Q(player=user) | Q(parties__in=Party.objects.managed_by(user)),
)
def mastered_by(self, user):
"""Return characters in groups where the given user is the game master."""
from party.models import Party
return self.filter(parties__in=Party.objects.managed_by(user))
def owned_by(self, user):
"""Return characters either owned by the given user."""
return self.filter(player=user)
def friendly_to(self, user):
"""
Return characters friendly to the given users.
Friendly characters are either owned by the given user
or in a party related to the given user.
"""
from party.models import Party
return self.filter(
Q(player=user)
| Q(parties__in=Party.objects.related_to(user))
| Q(invites__in=Party.objects.related_to(user)),
).distinct()
DEFAULT_NOTES = """
#### Traits personnalisés
#### Objectifs
#### Langages
#### Historique
#### Handicaps
#### Alignement
#### Relations
#### Religion
""".lstrip()
@dataclass
class CharacterCapability:
capability: Capability
known: bool = False
def validate_image(fieldfile_obj, megabytes_limit: float):
filesize = fieldfile_obj.file.size
if filesize > megabytes_limit * 1024 * 1024:
raise ValidationError("La taille maximale est de %sMo" % str(megabytes_limit))
class Character(models.Model):
class Gender(models.TextChoices):
MALE = "M", "Mâle"
FEMALE = "F", "Femelle"
OTHER = "O", "Autre"
name = models.CharField(max_length=100, verbose_name="nom")
player = models.ForeignKey(
"common.User",
on_delete=models.CASCADE,
related_name="characters",
verbose_name="joueur",
)
profile_picture = models.ImageField(
verbose_name="image de profil",
upload_to="profile_pictures",
blank=True,
null=True,
validators=[partial(validate_image, megabytes_limit=2)],
)
race = models.ForeignKey(
"character.Race",
on_delete=models.PROTECT,
related_name="characters",
verbose_name="race",
)
profile = models.ForeignKey(
"character.Profile",
on_delete=models.PROTECT,
related_name="characters",
verbose_name="profil",
)
level = models.PositiveSmallIntegerField(verbose_name="niveau", default=1)
gender = models.CharField(
max_length=1,
choices=Gender.choices,
default=Gender.OTHER,
verbose_name="genre",
)
age = models.PositiveSmallIntegerField(verbose_name="âge")
height = models.PositiveSmallIntegerField(verbose_name="taille")
weight = models.PositiveSmallIntegerField(verbose_name="poids")
value_strength = models.PositiveSmallIntegerField(verbose_name="valeur force")
value_dexterity = models.PositiveSmallIntegerField(verbose_name="valeur dextérité")
value_constitution = models.PositiveSmallIntegerField(
verbose_name="valeur constitution",
)
value_intelligence = models.PositiveSmallIntegerField(
verbose_name="valeur intelligence",
)
value_wisdom = models.PositiveSmallIntegerField(verbose_name="valeur sagesse")
value_charisma = models.PositiveSmallIntegerField(verbose_name="valeur charisme")
health_max = models.PositiveSmallIntegerField(verbose_name="points de vie max")
health_remaining = models.PositiveSmallIntegerField(
verbose_name="points de vie restants",
)
racial_capability = models.ForeignKey(
"character.RacialCapability",
on_delete=models.PROTECT,
related_name="characters",
verbose_name="capacité raciale",
)
weapons = models.ManyToManyField(
"character.Weapon",
blank=True,
verbose_name="armes",
)
armor = models.PositiveSmallIntegerField(verbose_name="armure", default=0)
shield = models.PositiveSmallIntegerField(verbose_name="bouclier", default=0)
defense_misc = models.SmallIntegerField(verbose_name="divers défense", default=0)
initiative_misc = models.SmallIntegerField(
verbose_name="divers initiative",
default=0,
)
capabilities = models.ManyToManyField(
"character.Capability",
blank=True,
verbose_name="capacités",
)
equipment = models.TextField(blank=True, verbose_name="équipement")
luck_points_remaining = models.PositiveSmallIntegerField(
verbose_name="points de chance restants",
)
mana_remaining = models.PositiveSmallIntegerField(
default=0,
verbose_name="mana restant",
)
money_pp = models.PositiveSmallIntegerField(default=0, verbose_name="PP")
money_po = models.PositiveSmallIntegerField(default=0, verbose_name="PO")
money_pa = models.PositiveSmallIntegerField(default=0, verbose_name="PA")
money_pc = models.PositiveSmallIntegerField(default=0, verbose_name="PC")
recovery_points_remaining = models.PositiveSmallIntegerField(
default=5,
verbose_name="points de récupération restants",
)
notes = models.TextField(blank=True, verbose_name="notes", default=DEFAULT_NOTES)
gm_notes = models.TextField(blank=True, verbose_name="notes MJ")
damage_reduction = models.TextField(blank=True, verbose_name="réduction de dégâts")
states = models.ManyToManyField(HarmfulState, blank=True, related_name="characters")
private = models.BooleanField(
"privé",
help_text="Empêche toute invitation dans un groupe.",
default=False,
blank=True,
)
objects = CharacterManager.from_queryset(CharacterQuerySet)()
class Meta:
verbose_name = "Personnage"
verbose_name_plural = "Personnages"
constraints = [
models.UniqueConstraint(
Lower("name"),
"player",
name="unique_character_player",
),
]
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse("character:view", kwargs={"pk": self.pk})
def natural_key(self):
return (self.name, self.player_id)
@property
def modifier_strength(self) -> int:
return modifier(self.value_strength)
@property
def modifier_dexterity(self) -> int:
return modifier(self.value_dexterity)
@property
def modifier_constitution(self) -> int:
return modifier(self.value_constitution)
@property
def modifier_intelligence(self) -> int:
return modifier(self.value_intelligence)
@property
def modifier_wisdom(self) -> int:
return modifier(self.value_wisdom)
@property
def modifier_charisma(self) -> int:
return modifier(self.value_charisma)
@property
def modifier_initiative(self) -> int:
return self.modifier_dexterity + self.initiative_misc
@property
def attack_melee(self) -> int:
return self.level + self.modifier_strength
@property
def attack_range(self) -> int:
return self.level + self.modifier_dexterity
@property
def attack_magic(self) -> int:
return self.level + self.modifier_magic
@property
def modifier_magic(self) -> int:
modifier_map = {
Profile.MagicalStrength.INTELLIGENCE: self.modifier_intelligence,
Profile.MagicalStrength.WISDOM: self.modifier_wisdom,
Profile.MagicalStrength.CHARISMA: self.modifier_charisma,
Profile.MagicalStrength.NONE: 0,
}
return modifier_map.get(
Profile.MagicalStrength(self.profile.magical_strength),
0,
)
@property
def defense(self) -> int:
return (
10 + self.armor + self.shield + self.modifier_dexterity + self.defense_misc
)
@property
def mana_max(self) -> int:
mana_max_compute = self.profile.mana_max_compute
if mana_max_compute == Profile.ManaMax.NO_MANA:
return 0
if mana_max_compute == Profile.ManaMax.LEVEL:
return self.level + self.modifier_magic
return 2 * self.level + self.modifier_magic
@property
def height_m(self) -> float:
return round(self.height / 100, 2)
@property
def imc(self) -> float:
return self.weight / (self.height_m**2)
@property
def recovery_points_max(self) -> int:
return 5
@property
def luck_points_max(self) -> int:
return max([3 + self.modifier_charisma, 0])
@property
def health_remaining_percent(self) -> float:
if self.health_max == 0:
return 0
return self.health_remaining / self.health_max * 100
@property
def mana_remaining_percent(self) -> float:
if self.mana_max == 0:
return 0
return self.mana_remaining / self.mana_max * 100
@property
def capability_points_max(self) -> int:
return 2 * self.level
@property
def capability_points_used(self) -> int:
return sum(cap.capability_points_cost for cap in self.capabilities.only("rank"))
@property
def capability_points_remaining(self) -> int:
return self.capability_points_max - self.capability_points_used
def get_modifier_for_weapon(self, weapon: Weapon) -> int:
modifier_map = {
Weapon.Category.RANGE: self.modifier_dexterity,
Weapon.Category.MELEE: self.modifier_strength,
Weapon.Category.NONE: 0,
}
return modifier_map.get(Weapon.Category(weapon.category), 0) + self.level
def get_capabilities_by_path(self) -> dict[Path, list[CharacterCapability]]:
capabilities_by_path = collections.defaultdict(list)
character_capabilities = self.capabilities.all()
character_paths = {capability.path for capability in character_capabilities}
for path in character_paths:
for capability in path.capabilities.all():
capabilities_by_path[capability.path].append(
CharacterCapability(
capability,
known=capability in character_capabilities,
),
)
return dict(
sorted(
(
(path, sorted(capabilities, key=lambda x: x.capability.rank))
for path, capabilities in capabilities_by_path.items()
),
key=lambda x: x[0].name,
),
)
def get_formatted_notes(self) -> str:
md = markdown.Markdown(extensions=["extra", "nl2br"])
return md.convert(self.notes)
def get_formatted_gm_notes(self) -> str:
md = markdown.Markdown(extensions=["extra", "nl2br"])
return md.convert(self.gm_notes)
def get_missing_states(self) -> Iterable[HarmfulState]:
return HarmfulState.objects.exclude(
pk__in=self.states.all().values_list("pk", flat=True),
)
def managed_by(self, user):
return self in Character.objects.managed_by(user)
def mastered_by(self, user):
return self in Character.objects.mastered_by(user)
def owned_by(self, user):
return self in Character.objects.owned_by(user)
def reset_stats(self):
self.health_remaining = self.health_max
self.mana_remaining = self.mana_max
self.luck_points_remaining = self.luck_points_max
self.recovery_points_remaining = self.recovery_points_max
self.save()