New progress.Character model: an admin-managed catalog (same pattern as subscription.Plan) of selectable mascots, each unlocked once the user's XP crosses unlock_xp. No separate ownership table yet — unlocking is a pure function of UserStats.xp, kept intentionally minimal since this is the first of an incremental rollout the senior asked for. - GET /api/characters — catalog with per-user unlocked/selected flags - POST /api/characters/select — picks an active character, rejecting ones the user hasn't unlocked yet - UserStats.selected_character_id tracks the pick - seed_test_data now seeds 4 sample characters (owl/fox/turtle/lion) for local/staging testing Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
123 lines
4.9 KiB
Python
123 lines
4.9 KiB
Python
from datetime import timedelta
|
|
|
|
from django.db import models
|
|
from django.utils import timezone
|
|
|
|
HEART_REFILL_INTERVAL = timedelta(hours=24)
|
|
|
|
|
|
class UserStats(models.Model):
|
|
user_id = models.IntegerField(unique=True, db_index=True) # ارجاع سبک به accounts.User
|
|
xp = models.PositiveIntegerField(default=0)
|
|
coins = models.PositiveIntegerField(default=0)
|
|
progress_percent = models.FloatField(default=0)
|
|
# ارجاع سبک به Character؛ کاراکترِ انتخابیِ کاربر (مثلِ ماسکاتِ Duolingo).
|
|
# میتونه به کاراکترِ هنوز بازنشده اشاره نکنه — CharacterSelectView قبل از
|
|
# ستکردن چک میکنه unlock_xp <= xp.
|
|
selected_character_id = models.IntegerField(null=True, blank=True)
|
|
|
|
def __str__(self):
|
|
return f"stats-user{self.user_id}"
|
|
|
|
|
|
class LeaderboardEntry(models.Model):
|
|
PERIOD_CHOICES = [('weekly', 'هفتگی'), ('all_time', 'کل زمان')]
|
|
|
|
user_id = models.IntegerField(db_index=True)
|
|
period = models.CharField(max_length=10, choices=PERIOD_CHOICES)
|
|
xp_in_period = models.PositiveIntegerField(default=0)
|
|
rank = models.PositiveIntegerField()
|
|
|
|
class Meta:
|
|
ordering = ['rank']
|
|
|
|
def __str__(self):
|
|
return f"lb-{self.period}-user{self.user_id}-rank{self.rank}"
|
|
|
|
|
|
class StreakRecord(models.Model):
|
|
user_id = models.IntegerField(unique=True, db_index=True)
|
|
current_streak = models.PositiveIntegerField(default=0)
|
|
longest_streak = models.PositiveIntegerField(default=0)
|
|
|
|
def __str__(self):
|
|
return f"streak-user{self.user_id}"
|
|
|
|
|
|
class ActiveDay(models.Model):
|
|
streak = models.ForeignKey(StreakRecord, on_delete=models.CASCADE, related_name='active_days')
|
|
date = models.DateField()
|
|
|
|
class Meta:
|
|
ordering = ['date']
|
|
|
|
def __str__(self):
|
|
return str(self.date)
|
|
|
|
|
|
class UserHearts(models.Model):
|
|
user_id = models.IntegerField(unique=True, db_index=True)
|
|
current_hearts = models.PositiveSmallIntegerField(default=5)
|
|
max_hearts = models.PositiveSmallIntegerField(default=5)
|
|
next_refill_at = models.DateTimeField(null=True, blank=True)
|
|
|
|
def __str__(self):
|
|
return f"hearts-user{self.user_id}"
|
|
|
|
def apply_regen(self):
|
|
"""اگه از next_refill_at گذشته باشه، هارت(های) گذشته رو پر میکنه (چندتا هم
|
|
اگه کاربر چند بازهی HEART_REFILL_INTERVAL غایب بوده باشه). True یعنی
|
|
state عوض شده و باید save بشه."""
|
|
if self.current_hearts >= self.max_hearts:
|
|
if self.next_refill_at is not None:
|
|
self.next_refill_at = None
|
|
return True
|
|
return False
|
|
|
|
now = timezone.now()
|
|
if self.next_refill_at is None:
|
|
self.next_refill_at = now + HEART_REFILL_INTERVAL
|
|
return True
|
|
if now < self.next_refill_at:
|
|
return False
|
|
|
|
elapsed_intervals = 1 + (now - self.next_refill_at) // HEART_REFILL_INTERVAL
|
|
gained = min(elapsed_intervals, self.max_hearts - self.current_hearts)
|
|
self.current_hearts += gained
|
|
if self.current_hearts >= self.max_hearts:
|
|
self.next_refill_at = None
|
|
else:
|
|
self.next_refill_at += elapsed_intervals * HEART_REFILL_INTERVAL
|
|
return True
|
|
|
|
def lose_heart(self):
|
|
"""یک هارت کم میکنه؛ اگه تایمرِ refill از قبل در حال شمارش نیست، از الان شروعش میکنه."""
|
|
if self.current_hearts > 0:
|
|
self.current_hearts -= 1
|
|
if self.next_refill_at is None and self.current_hearts < self.max_hearts:
|
|
self.next_refill_at = timezone.now() + HEART_REFILL_INTERVAL
|
|
|
|
|
|
class Character(models.Model):
|
|
"""
|
|
کاراکترهای گیمیفیکیشن (مثلِ ماسکاتهای Duolingo) — یک کاتالوگِ ثابت که از
|
|
پنلِ ادمین مدیریت میشه (دقیقاً مثلِ subscription.Plan)، نه یک چیزی که
|
|
کاربر خودش میسازه. فعلاً باز/بستهبودن فقط تابعی از XP کاربره (بدونِ
|
|
جدولِ جداگانهی مالکیت)؛ اگه بعداً «خریدن با coin» یا شرطِ دیگهای لازم
|
|
شد، همینجا اضافه میشه.
|
|
"""
|
|
slug = models.SlugField(max_length=50, unique=True)
|
|
name = models.CharField(max_length=50)
|
|
# کلیدی که فرانت به تصویر/انیمیشنِ واقعی نگاشت میده — همون الگویِ
|
|
# content.Lesson.mascot_animation و accounts.User.avatar؛ فعلاً آپلودِ
|
|
# asset واقعی نداریم.
|
|
icon = models.CharField(max_length=100)
|
|
unlock_xp = models.PositiveIntegerField(default=0)
|
|
order = models.PositiveIntegerField(default=0)
|
|
|
|
class Meta:
|
|
ordering = ['order', 'unlock_xp']
|
|
|
|
def __str__(self):
|
|
return self.name
|