Files
MathApp/progress/models.py
Zareian 96f0204fcf refill hearts like Duolingo: one every 5 hours, all of them after 24
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>
2026-09-24 06:58:39 +03:30

157 lines
6.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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