Each heart used to take 24 hours, so a student who lost all five waited up to five days. Now one heart returns every 5 hours, and 24 hours after the first heart was lost they all come back at once (the owner's call). /api/user/hearts reports both times (nextRefillAt, fullRefillAt); an existing timer longer than 5 hours is shortened the first time it is read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
157 lines
6.7 KiB
Python
157 lines
6.7 KiB
Python
from datetime import timedelta
|
||
|
||
from django.db import models
|
||
from django.utils import timezone
|
||
|
||
# مثلِ دولینگو: هر ۵ ساعت یک قلب برمیگردد، و ۲۴ ساعت بعد از اولین قلبی که
|
||
# از دست رفت همه یکجا پر میشوند (تصمیمِ صاحبِ پروژه، ۲۰۲۶-۰۹-۲۴). قبلاً هر
|
||
# قلب ۲۴ ساعت بود؛ دانشآموزی که هر پنج را از دست میداد تا پنج روز میماند.
|
||
HEART_REFILL_INTERVAL = timedelta(hours=5)
|
||
HEART_FULL_REFILL_AFTER = 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)
|
||
# قلبِ بعدی کی برمیگردد (هر HEART_REFILL_INTERVAL یکی).
|
||
next_refill_at = models.DateTimeField(null=True, blank=True)
|
||
# همهی قلبها کی یکجا پر میشوند: HEART_FULL_REFILL_AFTER بعد از اولین
|
||
# قلبی که از حالتِ پر کم شد.
|
||
full_refill_at = models.DateTimeField(null=True, blank=True)
|
||
|
||
REGEN_FIELDS = ['current_hearts', 'next_refill_at', 'full_refill_at']
|
||
|
||
def __str__(self):
|
||
return f"hearts-user{self.user_id}"
|
||
|
||
def _refill_all(self):
|
||
self.current_hearts = self.max_hearts
|
||
self.next_refill_at = None
|
||
self.full_refill_at = None
|
||
|
||
def apply_regen(self):
|
||
"""
|
||
قلبهایی را که وقتشان رسیده برمیگرداند. True یعنی state عوض شده و باید
|
||
با REGEN_FIELDS ذخیره شود.
|
||
|
||
۱) اگر از full_refill_at گذشته باشد، همه یکجا پر میشوند.
|
||
۲) وگرنه به ازای هر HEART_REFILL_INTERVAL که از next_refill_at گذشته،
|
||
یک قلب (چندتا اگر کاربر مدتی نبوده).
|
||
"""
|
||
if self.current_hearts >= self.max_hearts:
|
||
if self.next_refill_at is not None or self.full_refill_at is not None:
|
||
self.next_refill_at = None
|
||
self.full_refill_at = None
|
||
return True
|
||
return False
|
||
|
||
now = timezone.now()
|
||
changed = False
|
||
if self.full_refill_at is None:
|
||
self.full_refill_at = now + HEART_FULL_REFILL_AFTER
|
||
changed = True
|
||
if self.next_refill_at is None or self.next_refill_at > now + HEART_REFILL_INTERVAL:
|
||
# نبودِ تایمر، یا تایمرِ ۲۴ساعتهی قدیمی از پیش از این تغییر.
|
||
self.next_refill_at = now + HEART_REFILL_INTERVAL
|
||
changed = True
|
||
|
||
if now >= self.full_refill_at:
|
||
self._refill_all()
|
||
return True
|
||
if now < self.next_refill_at:
|
||
return changed
|
||
|
||
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._refill_all()
|
||
else:
|
||
self.next_refill_at += elapsed_intervals * HEART_REFILL_INTERVAL
|
||
return True
|
||
|
||
def lose_heart(self):
|
||
"""یک قلب کم میکند و اگر تایمرها در حالِ شمارش نیستند، از الان شروعشان میکند."""
|
||
if self.current_hearts > 0:
|
||
self.current_hearts -= 1
|
||
if self.current_hearts < self.max_hearts:
|
||
now = timezone.now()
|
||
if self.next_refill_at is None:
|
||
self.next_refill_at = now + HEART_REFILL_INTERVAL
|
||
if self.full_refill_at is None:
|
||
self.full_refill_at = now + HEART_FULL_REFILL_AFTER
|
||
|
||
|
||
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
|