package bot import ( "fmt" "log/slog" "strings" "time" "worktimeBot/internal/db" ) // XP earned per second of work. const xpPerSecond int64 = 1 // xpForLevel returns the total XP required to reach level n. // Quadratic curve: totalXP(n) = n * (n + 1) * xpCurveMultiplier // Level 1 = 100 XP, Level 10 = 5500 XP, Level 50 = 127500 XP func xpForLevel(n int) int64 { return int64(n) * int64(n+1) * xpCurveMultiplier } // levelForXP returns the level for a given total XP amount. func levelForXP(xp int64) int { l := 1 for xpForLevel(l) <= xp { l++ } return l - 1 } // xpForWorkSeconds calculates XP earned for a given amount of work. // Base: 1 XP per second. func xpForWorkSeconds(sec int64) int64 { return sec / xpPerSecond } // streakBonus returns bonus XP for maintaining a streak. // +xpCurveMultiplier XP per day of current streak, capped at streakBonusCap. func streakBonus(streak int) int64 { b := int64(streak) * xpCurveMultiplier if b > streakBonusCap { b = streakBonusCap } return b } // computeAndAwardXP calculates XP for a work session and updates the DB. // Returns the new total XP and whether the user leveled up. func (h *Handler) computeAndAwardXP(userID int64, workSeconds int64, streak int, date string) (newXP int64, leveledUp bool, newLevel int, err error) { stats, err := h.DB.GetOrCreateRPGStats(userID) if err != nil { return 0, false, 0, err } baseXP := xpForWorkSeconds(workSeconds) bonus := streakBonus(streak) totalXP := baseXP + bonus if totalXP < 0 { totalXP = 0 } stats.XP += totalXP newLevel = levelForXP(stats.XP) leveledUp = newLevel > stats.Level stats.Level = newLevel stats.TotalWorkSeconds += workSeconds if err := h.DB.UpdateRPGStats(stats); err != nil { return 0, false, 0, err } if err := h.DB.UpsertDailyXP(userID, date, totalXP, workSeconds); err != nil { slog.Warn("failed to upsert daily xp", "user_id", userID, "error", err) } return stats.XP, leveledUp, newLevel, nil } // updateStreak advances or resets the user's streak based on today's activity. // Returns the updated streak count. func (h *Handler) updateStreak(userID int64, date string, hasWorkToday bool) (int, error) { stats, err := h.DB.GetOrCreateRPGStats(userID) if err != nil { return 0, err } if !hasWorkToday { stats.CurrentStreak = 0 if err := h.DB.UpdateRPGStats(stats); err != nil { return 0, err } return 0, nil } // Parse yesterday to check continuity t, err := time.Parse(DateLayout, date) if err != nil { return 0, err } yesterday := t.AddDate(0, 0, -1).Format(DateLayout) prevEvents, err := h.DB.EventsForDayByDate(userID, yesterday) hadWorkYesterday := err == nil && len(prevEvents) > 0 if hadWorkYesterday { stats.CurrentStreak++ } else { stats.CurrentStreak = 1 } if stats.CurrentStreak > stats.LongestStreak { stats.LongestStreak = stats.CurrentStreak } if err := h.DB.UpdateRPGStats(stats); err != nil { return 0, err } return stats.CurrentStreak, nil } // checkAchievements evaluates and unlocks any newly earned achievements. func (h *Handler) checkAchievements(userID int64, stats *db.RPGStats, workSeconds int64, date string, isWeekend bool) []string { unlocker := newAchievementUnlocker(h, userID) h.checkEarlyAchievements(unlocker, workSeconds) h.checkEventTimeAchievements(unlocker, userID, date) h.checkStreakAchievements(unlocker, stats) h.checkHourAchievements(unlocker, stats) h.checkSessionAchievements(unlocker, workSeconds, isWeekend) h.checkLevelAchievements(unlocker, stats) return unlocker.unlocked } type achievementUnlocker struct { h *Handler userID int64 unlocked []string } func newAchievementUnlocker(h *Handler, userID int64) *achievementUnlocker { return &achievementUnlocker{h: h, userID: userID} } func (u *achievementUnlocker) unlock(code string) { a, err := u.h.DB.GetAchievementByCode(code) if err != nil { return } ok, err := u.h.DB.UnlockAchievement(u.userID, a.ID) if err != nil { slog.Error("unlock achievement", "code", code, "error", err) return } if ok { u.unlocked = append(u.unlocked, a.Name) } } func (h *Handler) checkEarlyAchievements(u *achievementUnlocker, workSeconds int64) { has, _ := h.DB.HasAchievement(u.userID, "first_clockin") if !has && workSeconds > 0 { u.unlock("first_clockin") } } func (h *Handler) checkEventTimeAchievements(u *achievementUnlocker, userID int64, date string) { t, _ := time.Parse(DateLayout, date) dayStart := t.Unix() dayEnd := t.Add(24 * time.Hour).Unix() events, err := h.DB.EventsForDayByDate(userID, date) if err != nil { return } for _, e := range events { if e.OccurredAt < dayStart || e.OccurredAt > dayEnd { continue } et := time.Unix(e.OccurredAt, 0) hh, mm, _ := et.Clock() minutes := hh*60 + mm if e.EventType == "in" && minutes < 7*60 { u.unlock("early_bird") } if e.EventType == "out" && minutes >= 22*60 { u.unlock("night_owl") } } } func (h *Handler) checkStreakAchievements(u *achievementUnlocker, stats *db.RPGStats) { if stats.CurrentStreak >= 5 { u.unlock("iron_streak_5") } if stats.CurrentStreak >= 10 { u.unlock("iron_streak_10") } if stats.CurrentStreak >= 30 { u.unlock("iron_streak_30") } } func (h *Handler) checkHourAchievements(u *achievementUnlocker, stats *db.RPGStats) { hoursWorked := float64(stats.TotalWorkSeconds) / secondsPerHour if hoursWorked >= 100.0 { u.unlock("hundred_hours") } if hoursWorked >= 500.0 { u.unlock("five_hundred_hours") } } func (h *Handler) checkSessionAchievements(u *achievementUnlocker, workSeconds int64, isWeekend bool) { if workSeconds > 10*secondsPerHour { u.unlock("overtime_hero") } if isWeekend && workSeconds > 0 { u.unlock("weekend_warrior") } } func (h *Handler) checkLevelAchievements(u *achievementUnlocker, stats *db.RPGStats) { if stats.Level >= 5 { u.unlock("level_5") } if stats.Level >= 10 { u.unlock("level_10") } if stats.Level >= 25 { u.unlock("level_25") } if stats.Level >= 50 { u.unlock("level_50") } } // buildRPGStatus returns a formatted string showing the user's RPG stats. func (h *Handler) buildRPGStatus(userID int64) string { stats, err := h.DB.GetOrCreateRPGStats(userID) if err != nil { return "RPG stats unavailable" } nextLevelXP := xpForLevel(stats.Level + 1) currentLevelXP := xpForLevel(stats.Level) xpIntoLevel := stats.XP - currentLevelXP xpNeeded := nextLevelXP - currentLevelXP var b strings.Builder b.WriteString(fmt.Sprintf("Level: %d\n", stats.Level)) b.WriteString(fmt.Sprintf("XP: %d / %d (%d%%)\n", stats.XP, nextLevelXP, xpIntoLevel*100/xpNeeded)) b.WriteString(fmt.Sprintf("Streak: %d days (best: %d)\n", stats.CurrentStreak, stats.LongestStreak)) hoursTotal := float64(stats.TotalWorkSeconds) / secondsPerHour b.WriteString(fmt.Sprintf("Total work: %.1f hours\n", hoursTotal)) // Show achievements count achs, _ := h.DB.GetUserAchievements(userID) if len(achs) > 0 { b.WriteString(fmt.Sprintf("Achievements: %d unlocked\n", len(achs))) } return b.String() } // buildAchievementsList returns the user's unlocked achievements. func (h *Handler) buildAchievementsList(userID int64) string { achs, err := h.DB.GetUserAchievements(userID) if err != nil { return "Achievements unavailable" } if len(achs) == 0 { return "No achievements unlocked yet.\nWork consistently to earn them!" } var b strings.Builder b.WriteString(fmt.Sprintf("Achievements (%d):\n\n", len(achs))) for _, a := range achs { t := time.Unix(a.UnlockedAt, 0) b.WriteString(fmt.Sprintf(" %s\n %s - %s\n", a.Name, a.Description, t.Format(DateLayout))) } return b.String() } // rpgStatus returns the user's RPG stats. func (h *Handler) rpgStatus(chatID int64) (string, error) { user, err := h.getOrCreateUser(chatID) if err != nil { return "", err } st, err := h.DB.GetOrCreateSettings(user.ID) if err != nil { return "", err } if !st.RPGEnabled { return "RPG system is disabled. Enable it in Settings or use /rpgtoggle.", nil } return h.buildRPGStatus(user.ID), nil } // achievementsList returns the user's unlocked achievements. func (h *Handler) achievementsList(chatID int64) (string, error) { user, err := h.getOrCreateUser(chatID) if err != nil { return "", err } st, err := h.DB.GetOrCreateSettings(user.ID) if err != nil { return "", err } if !st.RPGEnabled { return "RPG system is disabled. Enable it in Settings or use /rpgtoggle.", nil } return h.buildAchievementsList(user.ID), nil }