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

@@ -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):