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>
This commit is contained in:
2026-09-24 06:58:39 +03:30
parent 701d8badcc
commit 96f0204fcf
8 changed files with 140 additions and 14 deletions

View File

@@ -426,7 +426,7 @@ class QuestionAnswerView(APIView):
hearts.apply_regen() hearts.apply_regen()
if not has_unlimited_hearts(user_id): if not has_unlimited_hearts(user_id):
hearts.lose_heart() hearts.lose_heart()
hearts.save(update_fields=['current_hearts', 'next_refill_at']) hearts.save(update_fields=UserHearts.REGEN_FIELDS)
hearts_remaining = hearts.current_hearts hearts_remaining = hearts.current_hearts
else: else:
hearts = UserHearts.objects.filter(user_id=user_id).first() hearts = UserHearts.objects.filter(user_id=user_id).first()

View File

@@ -123,6 +123,16 @@ PaymentRequest را می‌زند، `Order.gateway_authority` را ذخیره م
`all_time` از `UserStats.xp`، `weekly` از XPی درس‌ها و جزیره‌های ۷ روزِ اخیر. `all_time` از `UserStats.xp`، `weekly` از XPی درس‌ها و جزیره‌های ۷ روزِ اخیر.
`rebuild_leaderboard` هم از همین تابع می‌خواند و فقط جدولِ ادمین را پر می‌کند. `rebuild_leaderboard` هم از همین تابع می‌خواند و فقط جدولِ ادمین را پر می‌کند.
## قلب‌ها، مثلِ دولینگو
هر پاسخِ غلط یک قلب کم می‌کند (مشترکِ فعال قلب از دست نمی‌دهد). **هر ۵ ساعت یک
قلب برمی‌گردد** (`HEART_REFILL_INTERVAL`) و **۲۴ ساعت بعد از اولین قلبی که از
حالتِ پر کم شد، همه یک‌جا پر می‌شوند** (`HEART_FULL_REFILL_AFTER`، فیلدِ
`full_refill_at`). `GET /api/user/hearts` هر دو زمان را می‌دهد: `nextRefillAt`
و `fullRefillAt`. تا ۲۰۲۶-۰۹-۲۴ هر قلب ۲۴ ساعت بود؛ تایمرِ قدیمیِ بلندتر از ۵
ساعت در اولین خواندن کوتاه می‌شود. بک‌اند جوابِ با صفر قلب را رد نمی‌کند؛
توقف کار فرانت است.
## دیتابیس ## دیتابیس
هنوز SQLite است (`OPTIONS.timeout=20` تا قفل‌های هم‌زمانیِ نوشتن را تحمل کند). هنوز SQLite است (`OPTIONS.timeout=20` تا قفل‌های هم‌زمانیِ نوشتن را تحمل کند).

View File

@@ -33,4 +33,4 @@ class StreakRecordAdmin(admin.ModelAdmin):
@admin.register(UserHearts) @admin.register(UserHearts)
class UserHeartsAdmin(admin.ModelAdmin): class UserHeartsAdmin(admin.ModelAdmin):
list_display = ('id', 'user_id', 'current_hearts', 'max_hearts', 'next_refill_at') list_display = ('id', 'user_id', 'current_hearts', 'max_hearts', 'next_refill_at', 'full_refill_at')

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.17 on 2026-09-24 03:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('progress', '0002_character_userstats_selected_character_id'),
]
operations = [
migrations.AddField(
model_name='userhearts',
name='full_refill_at',
field=models.DateTimeField(blank=True, null=True),
),
]

View File

@@ -3,7 +3,11 @@ from datetime import timedelta
from django.db import models from django.db import models
from django.utils import timezone from django.utils import timezone
HEART_REFILL_INTERVAL = timedelta(hours=24) # مثلِ دولینگو: هر ۵ ساعت یک قلب برمی‌گردد، و ۲۴ ساعت بعد از اولین قلبی که
# از دست رفت همه یک‌جا پر می‌شوند (تصمیمِ صاحبِ پروژه، ۲۰۲۶-۰۹-۲۴). قبلاً هر
# قلب ۲۴ ساعت بود؛ دانش‌آموزی که هر پنج را از دست می‌داد تا پنج روز می‌ماند.
HEART_REFILL_INTERVAL = timedelta(hours=5)
HEART_FULL_REFILL_AFTER = timedelta(hours=24)
class UserStats(models.Model): class UserStats(models.Model):
@@ -59,43 +63,73 @@ class UserHearts(models.Model):
user_id = models.IntegerField(unique=True, db_index=True) user_id = models.IntegerField(unique=True, db_index=True)
current_hearts = models.PositiveSmallIntegerField(default=5) current_hearts = models.PositiveSmallIntegerField(default=5)
max_hearts = models.PositiveSmallIntegerField(default=5) max_hearts = models.PositiveSmallIntegerField(default=5)
# قلبِ بعدی کی برمی‌گردد (هر HEART_REFILL_INTERVAL یکی).
next_refill_at = models.DateTimeField(null=True, blank=True) 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): def __str__(self):
return f"hearts-user{self.user_id}" return f"hearts-user{self.user_id}"
def apply_regen(self): def _refill_all(self):
"""اگه از next_refill_at گذشته باشه، هارت(های) گذشته رو پر می‌کنه (چندتا هم self.current_hearts = self.max_hearts
اگه کاربر چند بازه‌ی 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 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 True
return False return False
now = timezone.now() now = timezone.now()
if self.next_refill_at is None: 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 self.next_refill_at = now + HEART_REFILL_INTERVAL
changed = True
if now >= self.full_refill_at:
self._refill_all()
return True return True
if now < self.next_refill_at: if now < self.next_refill_at:
return False return changed
elapsed_intervals = 1 + (now - self.next_refill_at) // HEART_REFILL_INTERVAL elapsed_intervals = 1 + (now - self.next_refill_at) // HEART_REFILL_INTERVAL
gained = min(elapsed_intervals, self.max_hearts - self.current_hearts) gained = min(elapsed_intervals, self.max_hearts - self.current_hearts)
self.current_hearts += gained self.current_hearts += gained
if self.current_hearts >= self.max_hearts: if self.current_hearts >= self.max_hearts:
self.next_refill_at = None self._refill_all()
else: else:
self.next_refill_at += elapsed_intervals * HEART_REFILL_INTERVAL self.next_refill_at += elapsed_intervals * HEART_REFILL_INTERVAL
return True return True
def lose_heart(self): def lose_heart(self):
"""یک هارت کم می‌کنه؛ اگه تایمرِ refill از قبل در حال شمارش نیست، از الان شروعش می‌کنه.""" """یک قلب کم می‌کند و اگر تایمرها در حالِ شمارش نیستند، از الان شروعشان می‌کند."""
if self.current_hearts > 0: if self.current_hearts > 0:
self.current_hearts -= 1 self.current_hearts -= 1
if self.next_refill_at is None and self.current_hearts < self.max_hearts: if self.current_hearts < self.max_hearts:
self.next_refill_at = timezone.now() + HEART_REFILL_INTERVAL 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): class Character(models.Model):

View File

@@ -39,6 +39,8 @@ class UserHeartsResponseSerializer(serializers.Serializer):
currentHearts = serializers.IntegerField() currentHearts = serializers.IntegerField()
maxHearts = serializers.IntegerField() maxHearts = serializers.IntegerField()
nextRefillAt = serializers.DateTimeField(allow_null=True) nextRefillAt = serializers.DateTimeField(allow_null=True)
# همه‌ی قلب‌ها کی یک‌جا پر می‌شوند (۲۴ ساعت بعد از اولین قلبِ ازدست‌رفته).
fullRefillAt = serializers.DateTimeField(allow_null=True)
isUnlimited = serializers.BooleanField() isUnlimited = serializers.BooleanField()

View File

@@ -129,3 +129,64 @@ class SummaryShowsTheActivePlanTests(TestCase):
Subscription.objects.create(user_id=user.id, plan=plan, status='active') Subscription.objects.create(user_id=user.id, plan=plan, status='active')
self.assertEqual(self.client.get('/api/user/summary', **auth).json()['subscription'], 'طلایی') self.assertEqual(self.client.get('/api/user/summary', **auth).json()['subscription'], 'طلایی')
class HeartsLikeDuolingoTests(TestCase):
"""هر ۵ ساعت یک قلب؛ ۲۴ ساعت بعد از اولین قلبِ ازدست‌رفته، همه پر."""
def _hearts_after_losing(self, n):
from progress.models import UserHearts
h = UserHearts.objects.create(user_id=1)
for _ in range(n):
h.lose_heart()
h.save()
return h
def _at(self, hours):
from django.utils import timezone
from datetime import timedelta
return mock.patch('django.utils.timezone.now', return_value=timezone.now() + timedelta(hours=hours))
def test_one_heart_comes_back_every_five_hours(self):
h = self._hearts_after_losing(5)
with self._at(4.9):
h.apply_regen()
self.assertEqual(h.current_hearts, 0)
with self._at(5.1):
h.apply_regen()
self.assertEqual(h.current_hearts, 1)
with self._at(15.1):
h.apply_regen()
self.assertEqual(h.current_hearts, 3)
def test_everything_refills_24_hours_after_the_first_loss(self):
h = self._hearts_after_losing(5)
with self._at(24.1):
h.apply_regen()
self.assertEqual(h.current_hearts, 5)
self.assertIsNone(h.next_refill_at)
self.assertIsNone(h.full_refill_at)
def test_an_old_24_hour_timer_is_shortened_to_five(self):
from datetime import timedelta
from django.utils import timezone
from progress.models import UserHearts
h = UserHearts.objects.create(
user_id=2, current_hearts=2, next_refill_at=timezone.now() + timedelta(hours=23),
)
h.apply_regen()
self.assertLessEqual(h.next_refill_at, timezone.now() + timedelta(hours=5, seconds=1))
self.assertIsNotNone(h.full_refill_at)
def test_api_reports_both_timers(self):
user = User.objects.create(phone='09120000033')
from progress.models import UserHearts
h = UserHearts.objects.create(user_id=user.id)
h.lose_heart()
h.save()
body = self.client.get(
'/api/user/hearts', HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}',
).json()
self.assertEqual(body['currentHearts'], 4)
self.assertIsNotNone(body['nextRefillAt'])
self.assertIsNotNone(body['fullRefillAt'])

View File

@@ -166,13 +166,14 @@ class UserHeartsView(APIView):
user = request.user user = request.user
hearts, _ = UserHearts.objects.get_or_create(user_id=user.id) hearts, _ = UserHearts.objects.get_or_create(user_id=user.id)
if hearts.apply_regen(): if hearts.apply_regen():
hearts.save(update_fields=['current_hearts', 'next_refill_at']) hearts.save(update_fields=UserHearts.REGEN_FIELDS)
unlimited = has_unlimited_hearts(user.id) unlimited = has_unlimited_hearts(user.id)
data = { data = {
'currentHearts': hearts.max_hearts if unlimited else hearts.current_hearts, 'currentHearts': hearts.max_hearts if unlimited else hearts.current_hearts,
'maxHearts': hearts.max_hearts, 'maxHearts': hearts.max_hearts,
'nextRefillAt': None if unlimited else hearts.next_refill_at, 'nextRefillAt': None if unlimited else hearts.next_refill_at,
'fullRefillAt': None if unlimited else hearts.full_refill_at,
'isUnlimited': unlimited, 'isUnlimited': unlimited,
} }
return Response(UserHeartsResponseSerializer(data).data) return Response(UserHeartsResponseSerializer(data).data)