diff --git a/internal/economy/economy.go b/internal/economy/economy.go index 6778671..335ffe6 100644 --- a/internal/economy/economy.go +++ b/internal/economy/economy.go @@ -61,15 +61,19 @@ type Profile struct { RankTier string `json:"rank_tier"` // bronze..king RankIndex int `json:"rank_index"` // ۰..۴ Avatar string `json:"avatar"` // seed آواتار (برای نمایش سرِ میز) + DailyStreak int `json:"daily_streak"` + BestStreak int `json:"best_streak"` + DailyReady bool `json:"daily_ready"` // آیا امروز سکه‌ی روزانه قابلِ دریافت است + NextDaily int64 `json:"next_daily"` // جایزه‌ی روزانه‌ی بعدی (پیش‌نمایش) } // GetProfile وضعیت اقتصادی کاربر را می‌خواند. func (s *Service) GetProfile(ctx context.Context, userID int64) (*Profile, error) { var p Profile - var vipUntil, avatar sql.NullString + var vipUntil, avatar, lastDaily sql.NullString err := s.db.QueryRowContext(ctx, - `SELECT coins, tickets, xp, trophies, vip_until, selected_card, selected_carpet, rank_points, avatar FROM users WHERE id = ?`, userID). - Scan(&p.Coins, &p.Tickets, &p.XP, &p.Trophies, &vipUntil, &p.SelectedCard, &p.SelectedCarpet, &p.RankPoints, &avatar) + `SELECT coins, tickets, xp, trophies, vip_until, selected_card, selected_carpet, rank_points, avatar, daily_streak, best_streak, last_daily_at FROM users WHERE id = ?`, userID). + Scan(&p.Coins, &p.Tickets, &p.XP, &p.Trophies, &vipUntil, &p.SelectedCard, &p.SelectedCarpet, &p.RankPoints, &avatar, &p.DailyStreak, &p.BestStreak, &lastDaily) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } @@ -83,6 +87,21 @@ func (s *Service) GetProfile(ctx context.Context, userID int64) (*Profile, error p.VIPUntil = &vipUntil.String p.VIP = parseTS(vipUntil.String).After(nowUTC()) } + // وضعیتِ سکه‌ی روزانه: اگر امروز (تهران) نگرفته، قابلِ دریافت است. اگر یک روز + // جا افتاده باشد، استریکِ بعدی از ۱ شروع می‌شود. + today := tehranDate(nowUTC()) + nextStreak := p.DailyStreak + 1 + if lastDaily.Valid { + lastDate := tehranDate(parseTS(lastDaily.String)) + p.DailyReady = lastDate != today + if lastDate != tehranDate(nowUTC().Add(-24*time.Hour)) && lastDate != today { + nextStreak = 1 // روزی جا افتاده ⇒ ریست + } + } else { + p.DailyReady = true + nextStreak = 1 + } + p.NextDaily = streakReward(nextStreak) return &p, nil } @@ -94,32 +113,70 @@ func (s *Service) isVIP(ctx context.Context, userID int64) bool { return vu.Valid && parseTS(vu.String).After(nowUTC()) } -// ClaimDaily سکه روزانه را در صورت گذشت ۲۴ ساعت می‌دهد. -func (s *Service) ClaimDaily(ctx context.Context, userID int64) (int64, error) { +// tehranDate تاریخِ تقویمیِ تهران (YYYY-MM-DD) را برمی‌گرداند. مرزِ «روز» برای +// استریک، نیمه‌شبِ تهران است تا با حسِ کاربر بخواند. +func tehranDate(t time.Time) string { return t.In(iranZone).Format("2006-01-02") } + +// streakReward جایزه‌ی روزِ n اُمِ استریک: پایه + پله (سقفِ روز ۷) + پاداشِ هفتگی. +func streakReward(streak int) int64 { + n := streak + if n > 7 { + n = 7 + } + r := int64(DailyReward) + int64(n-1)*50 // روز۱=۲۰۰ … روز۷=۵۰۰ + if streak%7 == 0 { // هر ۷ روز پیاپی، پاداشِ ویژه + r += 500 + } + return r +} + +// ClaimDaily سکه‌ی روزانه را (یک‌بار در هر روزِ تقویمیِ تهران) می‌دهد و استریک را +// به‌روزرسانی می‌کند. برمی‌گرداند: جایزه، استریکِ جدید، بهترین استریک. +func (s *Service) ClaimDaily(ctx context.Context, userID int64) (reward int64, streak int, best int, err error) { tx, err := s.db.BeginTx(ctx, nil) if err != nil { - return 0, err + return 0, 0, 0, err } defer tx.Rollback() - cutoff := nowUTC().Add(-24 * time.Hour).Format(tsLayout) - res, err := tx.ExecContext(ctx, - `UPDATE users SET last_daily_at = ? - WHERE id = ? AND (last_daily_at IS NULL OR last_daily_at <= ?)`, - nowStr(), userID, cutoff) - if err != nil { - return 0, err + var last sql.NullString + if err = tx.QueryRowContext(ctx, + `SELECT last_daily_at, daily_streak, best_streak FROM users WHERE id = ?`, userID). + Scan(&last, &streak, &best); err != nil { + return 0, 0, 0, err } - if n, _ := res.RowsAffected(); n == 0 { - return 0, ErrTooSoon + + today := tehranDate(nowUTC()) + if last.Valid { + lastDate := tehranDate(parseTS(last.String)) + if lastDate == today { + return 0, 0, 0, ErrTooSoon // امروز قبلاً گرفته + } + if lastDate == tehranDate(nowUTC().Add(-24*time.Hour)) { + streak++ // دیروز گرفته ⇒ ادامه‌ی استریک + } else { + streak = 1 // یک روز جا افتاده ⇒ ریست + } + } else { + streak = 1 } - if err := adjustTx(ctx, tx, userID, CurrencyCoin, DailyReward, "daily_reward", ""); err != nil { - return 0, err + if streak > best { + best = streak } - if err := tx.Commit(); err != nil { - return 0, err + reward = streakReward(streak) + + if _, err = tx.ExecContext(ctx, + `UPDATE users SET last_daily_at = ?, daily_streak = ?, best_streak = ? WHERE id = ?`, + nowStr(), streak, best, userID); err != nil { + return 0, 0, 0, err } - return DailyReward, nil + if err = adjustTx(ctx, tx, userID, CurrencyCoin, reward, "daily_reward", ""); err != nil { + return 0, 0, 0, err + } + if err = tx.Commit(); err != nil { + return 0, 0, 0, err + } + return reward, streak, best, nil } // ClaimAdReward پس از تأیید تبلیغ rewarded، سکه می‌دهد (با ضد تکرار و سقف روزانه). diff --git a/internal/economy/economy_test.go b/internal/economy/economy_test.go index df1fa61..f1b8f88 100644 --- a/internal/economy/economy_test.go +++ b/internal/economy/economy_test.go @@ -4,6 +4,7 @@ import ( "context" "path/filepath" "testing" + "time" "hakemsho/internal/store" ) @@ -87,10 +88,11 @@ func TestWalletInsufficient(t *testing.T) { func TestDailyCooldown(t *testing.T) { svc, id := setup(t) ctx := context.Background() - if amt, err := svc.ClaimDaily(ctx, id); err != nil || amt != DailyReward { - t.Fatalf("first claim: amt=%d err=%v", amt, err) + // اولین دریافت: استریک ۱، جایزه = DailyReward. + if amt, streak, _, err := svc.ClaimDaily(ctx, id); err != nil || amt != DailyReward || streak != 1 { + t.Fatalf("first claim: amt=%d streak=%d err=%v", amt, streak, err) } - if _, err := svc.ClaimDaily(ctx, id); err != ErrTooSoon { + if _, _, _, err := svc.ClaimDaily(ctx, id); err != ErrTooSoon { t.Fatalf("want ErrTooSoon, got %v", err) } if c := coinsOf(t, svc, id); c != DailyReward { @@ -98,6 +100,33 @@ func TestDailyCooldown(t *testing.T) { } } +func TestDailyStreakContinuesAndResets(t *testing.T) { + svc, id := setup(t) + ctx := context.Background() + // دیروز گرفته ⇒ امروز استریک به ۲ می‌رسد و جایزه بیشتر می‌شود. + ySet := func(daysAgo int) { + ts := nowUTC().Add(time.Duration(-daysAgo) * 24 * time.Hour).Format(tsLayout) + if _, err := svc.db.Exec(`UPDATE users SET last_daily_at=?, daily_streak=1 WHERE id=?`, ts, id); err != nil { + t.Fatal(err) + } + } + ySet(1) + amt, streak, _, err := svc.ClaimDaily(ctx, id) + if err != nil || streak != 2 || amt != streakReward(2) { + t.Fatalf("continue: amt=%d streak=%d err=%v", amt, streak, err) + } + // دو روز فاصله ⇒ استریک ریست به ۱. + if _, err := svc.db.Exec( + `UPDATE users SET last_daily_at=?, daily_streak=5 WHERE id=?`, + nowUTC().Add(-48*time.Hour).Format(tsLayout), id); err != nil { + t.Fatal(err) + } + _, streak, _, err = svc.ClaimDaily(ctx, id) + if err != nil || streak != 1 { + t.Fatalf("reset: streak=%d err=%v", streak, err) + } +} + func TestAdRewardDedupeAndCredit(t *testing.T) { svc, id := setup(t) ctx := context.Background() diff --git a/internal/economy/handler.go b/internal/economy/handler.go index 8e0f8f2..114da1d 100644 --- a/internal/economy/handler.go +++ b/internal/economy/handler.go @@ -375,9 +375,11 @@ func (h *Handler) Daily(w http.ResponseWriter, r *http.Request) { httpx.Error(w, http.StatusUnauthorized, "unauthorized") return } - switch amount, err := h.svc.ClaimDaily(r.Context(), id); { + switch amount, streak, best, err := h.svc.ClaimDaily(r.Context(), id); { case err == nil: - httpx.JSON(w, http.StatusOK, map[string]any{"message": "ok", "amount": amount}) + httpx.JSON(w, http.StatusOK, map[string]any{ + "message": "ok", "amount": amount, "streak": streak, "best": best, + }) case errors.Is(err, ErrTooSoon): httpx.Error(w, http.StatusTooManyRequests, "already claimed today") default: diff --git a/internal/store/migrations/013_streak.sql b/internal/store/migrations/013_streak.sql new file mode 100644 index 0000000..a21afa5 --- /dev/null +++ b/internal/store/migrations/013_streak.sql @@ -0,0 +1,4 @@ +-- استریکِ روزانه (مثلِ دولینگو): تعدادِ روزهای پیاپی که کاربر سکه‌ی روزانه گرفته. +-- با از دست دادنِ یک روز صفر می‌شود؛ جایزه با طولِ استریک زیاد می‌شود. +ALTER TABLE users ADD COLUMN daily_streak INTEGER NOT NULL DEFAULT 0; +ALTER TABLE users ADD COLUMN best_streak INTEGER NOT NULL DEFAULT 0;