From a72066a62ae7559b93431999732222b6e35cb6d1 Mon Sep 17 00:00:00 2001 From: Dylan Date: Wed, 4 Nov 2026 16:40:10 +0100 Subject: [PATCH] feat: add progression models and service structure --- achivements/services/evaluate_achievements.py | 1 + apps/accounts/models.py | 40 ++++- apps/achievements/models.py | 70 +++++++++ apps/progression/models.py | 142 +++++++++++++++++- apps/skills/apps.py | 4 +- apps/skills/models.py | 99 +++++++++++- progression/services/award_xp.py | 1 + progression/services/calculate_level.py | 1 + progression/services/complete_task.py | 1 + progression/services/reverse_completion.py | 1 + 10 files changed, 354 insertions(+), 6 deletions(-) create mode 100644 achivements/services/evaluate_achievements.py create mode 100644 progression/services/award_xp.py create mode 100644 progression/services/calculate_level.py create mode 100644 progression/services/complete_task.py create mode 100644 progression/services/reverse_completion.py diff --git a/achivements/services/evaluate_achievements.py b/achivements/services/evaluate_achievements.py new file mode 100644 index 0000000..9fda8ce --- /dev/null +++ b/achivements/services/evaluate_achievements.py @@ -0,0 +1 @@ +Service chargé de valider la réalisation d'une tâche. diff --git a/apps/accounts/models.py b/apps/accounts/models.py index 71a8362..fe9ba72 100644 --- a/apps/accounts/models.py +++ b/apps/accounts/models.py @@ -1,3 +1,39 @@ -from django.db import models +# apps/accounts/models.py -# Create your models here. +import uuid + +from django.conf import settings +from django.db import models +from django.contrib.auth.models import AbstractUser + +class User(AbstractUser): + pass + +class PlayerProfile(models.Model): + class Visibility(models.TextChoices): + PRIVATE = "private", "Privé" + LOCAL = "local", "Instance locale" + FEDERATED = "federated", "Public et fédéré" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + user = models.OneToOneField( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="player_profile", + ) + handle = models.SlugField(max_length=50, unique=True) + display_name = models.CharField(max_length=100) + bio = models.TextField(blank=True) + avatar = models.ImageField(upload_to="avatars/", blank=True) + + visibility = models.CharField( + max_length=16, + choices=Visibility.choices, + default=Visibility.PRIVATE, + ) + + # Identifiant stable exposé à la fédération + public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) diff --git a/apps/achievements/models.py b/apps/achievements/models.py index 71a8362..035cb3e 100644 --- a/apps/achievements/models.py +++ b/apps/achievements/models.py @@ -1,3 +1,73 @@ +# apps/achivements/models.py + +import uuid + +from django.conf import settings from django.db import models # Create your models here. +class Achievement(models.Model): + class RuleType(models.TextChoices): + TASK_COUNT = "task_count", "Nombre de réalisations" + SKILL_XP = "skill_xp", "XP dans une compétence" + TREE_XP = "tree_xp", "XP dans un arbre" + TOTAL_XP = "total_xp", "XP total" + DISTINCT_TASKS = "distinct_tasks", "Tâches différentes" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + slug = models.SlugField(max_length=100, unique=True) + name = models.CharField(max_length=160) + description = models.TextField(blank=True) + icon = models.CharField(max_length=80, blank=True) + + rule_type = models.CharField(max_length=32, choices=RuleType.choices) + rule_config = models.JSONField(default=dict) + + xp_reward = models.PositiveIntegerField(default=0) + is_secret = models.BooleanField(default=False) + is_active = models.BooleanField(default=True) + + +class PlayerAchievement(models.Model): + player = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="achievements", + ) + achievement = models.ForeignKey( + Achievement, + on_delete=models.CASCADE, + related_name="players", + ) + unlocked_at = models.DateTimeField(auto_now_add=True) + progress_snapshot = models.JSONField(default=dict) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=("player", "achievement"), + name="unique_player_achievement", + ) + ] + + +class Title(models.Model): + slug = models.SlugField(max_length=100, unique=True) + name = models.CharField(max_length=120) + description = models.TextField(blank=True) + achievement = models.OneToOneField( + Achievement, + on_delete=models.CASCADE, + related_name="title", + ) + + +class PlayerTitle(models.Model): + player = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="titles", + ) + title = models.ForeignKey(Title, on_delete=models.CASCADE) + unlocked_at = models.DateTimeField(auto_now_add=True) + is_equipped = models.BooleanField(default=False) diff --git a/apps/progression/models.py b/apps/progression/models.py index 71a8362..cb3cfb0 100644 --- a/apps/progression/models.py +++ b/apps/progression/models.py @@ -1,3 +1,143 @@ +# apps/progression/models.py + +import uuid + +from django.conf import settings from django.db import models -# Create your models here. + +class TaskCompletion(models.Model): + class Status(models.TextChoices): + PENDING = "pending", "En attente" + ACCEPTED = "accepted", "Acceptée" + REJECTED = "rejected", "Refusée" + CANCELLED = "cancelled", "Annulée" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + player = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="task_completions", + ) + task = models.ForeignKey( + "skills.Task", + on_delete=models.PROTECT, + related_name="completions", + ) + + status = models.CharField( + max_length=16, + choices=Status.choices, + default=Status.ACCEPTED, + ) + + completed_at = models.DateTimeField() + submitted_at = models.DateTimeField(auto_now_add=True) + + note = models.TextField(blank=True) + proof_url = models.URLField(blank=True) + + # Évite les doubles créations lors d’un retry de l’API. + idempotency_key = models.CharField( + max_length=100, + null=True, + blank=True, + ) + + # Snapshot au moment de la validation + task_name_snapshot = models.CharField(max_length=160) + xp_snapshot = models.PositiveIntegerField(default=0) + + validated_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="validated_completions", + ) + validated_at = models.DateTimeField(null=True, blank=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=("player", "idempotency_key"), + condition=models.Q(idempotency_key__isnull=False), + name="unique_player_idempotency_key", + ) + ] + indexes = [ + models.Index(fields=("player", "-completed_at")), + models.Index(fields=("task", "-completed_at")), + ] + +class XPTransaction(models.Model): + class Reason(models.TextChoices): + TASK = "task", "Réalisation d’une tâche" + ACHIEVEMENT = "achievement", "Succès" + BONUS = "bonus", "Bonus" + CORRECTION = "correction", "Correction" + REVERSAL = "reversal", "Annulation" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + player = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="xp_transactions", + ) + skill = models.ForeignKey( + "skills.Skill", + on_delete=models.PROTECT, + related_name="xp_transactions", + ) + + # Signé : positif ou négatif + amount = models.IntegerField() + reason = models.CharField(max_length=20, choices=Reason.choices) + + completion = models.ForeignKey( + TaskCompletion, + null=True, + blank=True, + on_delete=models.PROTECT, + related_name="xp_transactions", + ) + reversed_transaction = models.OneToOneField( + "self", + null=True, + blank=True, + on_delete=models.PROTECT, + related_name="reversal", + ) + + metadata = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + indexes = [ + models.Index(fields=("player", "skill")), + models.Index(fields=("player", "-created_at")), + ] + +class PlayerSkillProgress(models.Model): + player = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="skill_progress", + ) + skill = models.ForeignKey( + "skills.Skill", + on_delete=models.CASCADE, + related_name="player_progress", + ) + xp = models.PositiveBigIntegerField(default=0) + level = models.PositiveIntegerField(default=0) + unlocked_at = models.DateTimeField(null=True, blank=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=("player", "skill"), + name="unique_player_skill_progress", + ) + ] diff --git a/apps/skills/apps.py b/apps/skills/apps.py index f76a116..599dfe3 100644 --- a/apps/skills/apps.py +++ b/apps/skills/apps.py @@ -2,5 +2,5 @@ from django.apps import AppConfig class SkillsConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" - name = 'apps.skills' + default_auto_field = "django.db.models.BigAutoField" + name = 'apps.skills' diff --git a/apps/skills/models.py b/apps/skills/models.py index 71a8362..b8a124e 100644 --- a/apps/skills/models.py +++ b/apps/skills/models.py @@ -1,3 +1,100 @@ +# apps/skills/models.py + +import uuid from django.db import models -# Create your models here. + +class SkillTree(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + slug = models.SlugField(max_length=80, unique=True) + name = models.CharField(max_length=120) + description = models.TextField(blank=True) + icon = models.CharField(max_length=80, blank=True) + color = models.CharField(max_length=7, blank=True) + is_active = models.BooleanField(default=True) + sort_order = models.PositiveIntegerField(default=0) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + +class Skill(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + tree = models.ForeignKey( + SkillTree, + on_delete=models.CASCADE, + related_name="skills", + ) + slug = models.SlugField(max_length=80) + name = models.CharField(max_length=120) + description = models.TextField(blank=True) + icon = models.CharField(max_length=80, blank=True) + + # Utilisable pour afficher le graphe + position_x = models.IntegerField(default=0) + position_y = models.IntegerField(default=0) + sort_order = models.PositiveIntegerField(default=0) + + is_active = models.BooleanField(default=True) + prerequisites = models.ManyToManyField( + "self", + symmetrical=False, + blank=True, + related_name="unlocks", + ) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=("tree", "slug"), + name="unique_skill_slug_per_tree", + ) + ] + +class Task(models.Model): + class ValidationMode(models.TextChoices): + SELF = "self", "Auto-validation" + PEER = "peer", "Validation par un tiers" + PROOF = "proof", "Preuve demandée" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + slug = models.SlugField(max_length=100, unique=True) + name = models.CharField(max_length=160) + description = models.TextField(blank=True) + + base_xp = models.PositiveIntegerField() + validation_mode = models.CharField( + max_length=16, + choices=ValidationMode.choices, + default=ValidationMode.SELF, + ) + + cooldown_seconds = models.PositiveIntegerField(default=0) + daily_limit = models.PositiveIntegerField(null=True, blank=True) + is_repeatable = models.BooleanField(default=True) + is_active = models.BooleanField(default=True) + + skills = models.ManyToManyField( + Skill, + through="TaskSkillReward", + related_name="tasks", + ) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + +class TaskSkillReward(models.Model): + task = models.ForeignKey(Task, on_delete=models.CASCADE) + skill = models.ForeignKey(Skill, on_delete=models.CASCADE) + + # Permet, par exemple, de répartir 10 XP entre maison et écologie. + xp = models.PositiveIntegerField() + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=("task", "skill"), + name="unique_reward_per_task_skill", + ) + ] diff --git a/progression/services/award_xp.py b/progression/services/award_xp.py new file mode 100644 index 0000000..9fda8ce --- /dev/null +++ b/progression/services/award_xp.py @@ -0,0 +1 @@ +Service chargé de valider la réalisation d'une tâche. diff --git a/progression/services/calculate_level.py b/progression/services/calculate_level.py new file mode 100644 index 0000000..9fda8ce --- /dev/null +++ b/progression/services/calculate_level.py @@ -0,0 +1 @@ +Service chargé de valider la réalisation d'une tâche. diff --git a/progression/services/complete_task.py b/progression/services/complete_task.py new file mode 100644 index 0000000..9fda8ce --- /dev/null +++ b/progression/services/complete_task.py @@ -0,0 +1 @@ +Service chargé de valider la réalisation d'une tâche. diff --git a/progression/services/reverse_completion.py b/progression/services/reverse_completion.py new file mode 100644 index 0000000..9fda8ce --- /dev/null +++ b/progression/services/reverse_completion.py @@ -0,0 +1 @@ +Service chargé de valider la réalisation d'une tâche.