From 96f0204fcf6016a270fe70626a7e8511cd9a7863 Mon Sep 17 00:00:00 2001 From: Zareian Date: Thu, 24 Sep 2026 06:58:39 +0330 Subject: [PATCH] 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 --- content/views.py | 2 +- docs/architecture.md | 10 +++ progress/admin.py | 2 +- .../0003_userhearts_full_refill_at.py | 18 ++++++ progress/models.py | 56 +++++++++++++---- progress/serializers.py | 2 + progress/tests_live_gamification.py | 61 +++++++++++++++++++ progress/views.py | 3 +- 8 files changed, 140 insertions(+), 14 deletions(-) create mode 100644 progress/migrations/0003_userhearts_full_refill_at.py diff --git a/content/views.py b/content/views.py index 99a3e74..6cb2216 100644 --- a/content/views.py +++ b/content/views.py @@ -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() diff --git a/docs/architecture.md b/docs/architecture.md index 8ad563a..aefd3f5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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` تا قفل‌های هم‌زمانیِ نوشتن را تحمل کند). diff --git a/progress/admin.py b/progress/admin.py index 6644a22..6307379 100644 --- a/progress/admin.py +++ b/progress/admin.py @@ -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') diff --git a/progress/migrations/0003_userhearts_full_refill_at.py b/progress/migrations/0003_userhearts_full_refill_at.py new file mode 100644 index 0000000..2e28da4 --- /dev/null +++ b/progress/migrations/0003_userhearts_full_refill_at.py @@ -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), + ), + ] diff --git a/progress/models.py b/progress/models.py index 12849f3..1680ec2 100644 --- a/progress/models.py +++ b/progress/models.py @@ -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 _refill_all(self): + self.current_hearts = self.max_hearts + self.next_refill_at = None + self.full_refill_at = None + def apply_regen(self): - """اگه از next_refill_at گذشته باشه، هارت(های) گذشته رو پر می‌کنه (چندتا هم - اگه کاربر چند بازه‌ی HEART_REFILL_INTERVAL غایب بوده باشه). True یعنی - state عوض شده و باید save بشه.""" + """ + قلب‌هایی را که وقتشان رسیده برمی‌گرداند. 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: + 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): diff --git a/progress/serializers.py b/progress/serializers.py index 473f2b3..a0d59cd 100644 --- a/progress/serializers.py +++ b/progress/serializers.py @@ -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() diff --git a/progress/tests_live_gamification.py b/progress/tests_live_gamification.py index 85669a4..60c605b 100644 --- a/progress/tests_live_gamification.py +++ b/progress/tests_live_gamification.py @@ -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']) diff --git a/progress/views.py b/progress/views.py index 5b502a1..ab5be36 100644 --- a/progress/views.py +++ b/progress/views.py @@ -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)