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:
@@ -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()
|
||||||
|
|||||||
@@ -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` تا قفلهای همزمانیِ نوشتن را تحمل کند).
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
18
progress/migrations/0003_userhearts_full_refill_at.py
Normal file
18
progress/migrations/0003_userhearts_full_refill_at.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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'])
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user