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()
|
||||
if not has_unlimited_hearts(user_id):
|
||||
hearts.lose_heart()
|
||||
hearts.save(update_fields=['current_hearts', 'next_refill_at'])
|
||||
hearts.save(update_fields=UserHearts.REGEN_FIELDS)
|
||||
hearts_remaining = hearts.current_hearts
|
||||
else:
|
||||
hearts = UserHearts.objects.filter(user_id=user_id).first()
|
||||
|
||||
@@ -123,6 +123,16 @@ PaymentRequest را میزند، `Order.gateway_authority` را ذخیره م
|
||||
`all_time` از `UserStats.xp`، `weekly` از XPی درسها و جزیرههای ۷ روزِ اخیر.
|
||||
`rebuild_leaderboard` هم از همین تابع میخواند و فقط جدولِ ادمین را پر میکند.
|
||||
|
||||
## قلبها، مثلِ دولینگو
|
||||
|
||||
هر پاسخِ غلط یک قلب کم میکند (مشترکِ فعال قلب از دست نمیدهد). **هر ۵ ساعت یک
|
||||
قلب برمیگردد** (`HEART_REFILL_INTERVAL`) و **۲۴ ساعت بعد از اولین قلبی که از
|
||||
حالتِ پر کم شد، همه یکجا پر میشوند** (`HEART_FULL_REFILL_AFTER`، فیلدِ
|
||||
`full_refill_at`). `GET /api/user/hearts` هر دو زمان را میدهد: `nextRefillAt`
|
||||
و `fullRefillAt`. تا ۲۰۲۶-۰۹-۲۴ هر قلب ۲۴ ساعت بود؛ تایمرِ قدیمیِ بلندتر از ۵
|
||||
ساعت در اولین خواندن کوتاه میشود. بکاند جوابِ با صفر قلب را رد نمیکند؛
|
||||
توقف کار فرانت است.
|
||||
|
||||
## دیتابیس
|
||||
|
||||
هنوز SQLite است (`OPTIONS.timeout=20` تا قفلهای همزمانیِ نوشتن را تحمل کند).
|
||||
|
||||
@@ -33,4 +33,4 @@ class StreakRecordAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.register(UserHearts)
|
||||
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.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):
|
||||
@@ -59,43 +63,73 @@ 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 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:
|
||||
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()
|
||||
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
|
||||
changed = True
|
||||
|
||||
if now >= self.full_refill_at:
|
||||
self._refill_all()
|
||||
return True
|
||||
if now < self.next_refill_at:
|
||||
return False
|
||||
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.next_refill_at = None
|
||||
self._refill_all()
|
||||
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
|
||||
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):
|
||||
|
||||
@@ -39,6 +39,8 @@ class UserHeartsResponseSerializer(serializers.Serializer):
|
||||
currentHearts = serializers.IntegerField()
|
||||
maxHearts = serializers.IntegerField()
|
||||
nextRefillAt = serializers.DateTimeField(allow_null=True)
|
||||
# همهی قلبها کی یکجا پر میشوند (۲۴ ساعت بعد از اولین قلبِ ازدسترفته).
|
||||
fullRefillAt = serializers.DateTimeField(allow_null=True)
|
||||
isUnlimited = serializers.BooleanField()
|
||||
|
||||
|
||||
|
||||
@@ -129,3 +129,64 @@ class SummaryShowsTheActivePlanTests(TestCase):
|
||||
Subscription.objects.create(user_id=user.id, plan=plan, status='active')
|
||||
|
||||
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
|
||||
hearts, _ = UserHearts.objects.get_or_create(user_id=user.id)
|
||||
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)
|
||||
data = {
|
||||
'currentHearts': hearts.max_hearts if unlimited else hearts.current_hearts,
|
||||
'maxHearts': hearts.max_hearts,
|
||||
'nextRefillAt': None if unlimited else hearts.next_refill_at,
|
||||
'fullRefillAt': None if unlimited else hearts.full_refill_at,
|
||||
'isUnlimited': unlimited,
|
||||
}
|
||||
return Response(UserHeartsResponseSerializer(data).data)
|
||||
|
||||
Reference in New Issue
Block a user