Files
worktimeBot/internal/bot/rpg.go
db123 af23f80fbb fix: address audit findings — nil pointers, timezone bug, dead code, tests, README
- Fix nil pointer dereference in clock.go (guard GetOrCreateSettings and
  GetOrCreateRPGStats errors before accessing fields)
- Fix timezone bug in handleSetBreakMsg (was using system time.Now()
  instead of user's configured timezone)
- Remove dead code: sendExportMonthPicker (unused wrapper)
- Simplify xpForWorkSeconds (xpPerSecond=1, inline to just return sec)
- Extract computeStreakFromDates as pure testable function from
  recalcAggregates; add 6 tests covering empty, current, gaps,
  no-today, long streak, longest-not-current edge cases
- Update README with full feature documentation: RPG, League, Salary,
  Burnout, Work Types, Inline History Editing, all missing commands
2026-06-25 02:25:13 +03:30

451 lines
13 KiB
Go

package bot
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"github.com/go-telegram/bot/models"
"worktimeBot/internal/db"
)
// 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
}
// 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
}
// computeStreakFromDates computes current and longest streak from a sorted list of date strings.
// today is the reference date for the current streak.
func computeStreakFromDates(dates []string, today string) (current, longest int) {
if len(dates) == 0 {
return 0, 0
}
dateSet := make(map[string]struct{}, len(dates))
for _, d := range dates {
dateSet[d] = struct{}{}
}
// Current streak: count consecutive days backwards from today
if _, ok := dateSet[today]; ok {
current = 1
for i := 1; ; i++ {
t, _ := time.Parse(DateLayout, today)
prev := t.AddDate(0, 0, -i).Format(DateLayout)
if _, ok := dateSet[prev]; ok {
current++
} else {
break
}
}
}
// Longest streak: find longest consecutive run in sorted dates
longest = 0
run := 1
for i := 1; i < len(dates); i++ {
prev, _ := time.Parse(DateLayout, dates[i-1])
curr, _ := time.Parse(DateLayout, dates[i])
if curr.Sub(prev).Hours() == 24 {
run++
} else {
if run > longest {
longest = run
}
run = 1
}
}
if run > longest {
longest = run
}
return current, longest
}
// computeAndAwardXP calculates XP for a work session and persists it.
// stats must already have the streak updated before calling.
// Returns the new total XP, whether the user leveled up, and the new level.
func (h *Handler) computeAndAwardXP(stats *db.RPGStats, userID int64, workSeconds int64, streak int, date string) (newXP int64, leveledUp bool, newLevel int, err error) {
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.
// Only called from clockOut — incremental update, not a full scan.
func (h *Handler) updateStreak(stats *db.RPGStats, userID int64, date string) {
t, err := time.Parse(DateLayout, date)
if err != nil {
return
}
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
}
}
// recalcDayWorkSeconds recomputes work_seconds for a single day from events and updates daily_xp.
func (h *Handler) recalcDayWorkSeconds(userID int64, date string, loc *time.Location) {
events, err := h.DB.EventsForDayByDate(userID, date)
if err != nil {
return
}
day, err := h.DB.GetDay(userID, date)
if err != nil {
return
}
y, m, d := parseGregorianDateKey(date)
dayStart := time.Date(y, time.Month(m), d, 0, 0, 0, 0, loc).Unix()
dayEnd := time.Date(y, time.Month(m), d, 23, 59, 59, 0, loc).Unix()
totals := ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd)
if err := h.DB.SetDailyWorkSeconds(userID, date, totals.TotalSeconds); err != nil {
slog.Warn("failed to set daily work seconds", "user_id", userID, "date", date, "error", err)
}
}
// recalcAggregates recomputes total_work_seconds and streak from scratch after event edits.
func (h *Handler) recalcAggregates(userID int64, loc *time.Location) {
total, err := h.DB.SumDailyWorkSeconds(userID)
if err != nil {
slog.Warn("failed to sum daily work seconds", "user_id", userID, "error", err)
return
}
stats, err := h.DB.GetOrCreateRPGStats(userID)
if err != nil {
slog.Warn("failed to get rpg stats for recalc", "user_id", userID, "error", err)
return
}
stats.TotalWorkSeconds = total
// Recompute streak from scratch
dates, err := h.DB.GetDatesWithEvents(userID)
today := time.Now().In(loc).Format(DateLayout)
if err == nil {
stats.CurrentStreak, stats.LongestStreak = computeStreakFromDates(dates, today)
} else {
stats.CurrentStreak = 0
stats.LongestStreak = 0
}
if err := h.DB.UpdateRPGStats(stats); err != nil {
slog.Warn("failed to update rpg stats after recalc", "user_id", userID, "error", err)
}
}
// checkAchievements evaluates and unlocks any newly earned achievements.
func (h *Handler) checkAchievements(userID int64, stats *db.RPGStats, workSeconds int64, date string, isWeekend bool, totalHours float64) []string {
unlocker := newAchievementUnlocker(h, userID)
h.checkEarlyAchievements(unlocker, workSeconds)
h.checkEventTimeAchievements(unlocker, userID, date)
h.checkStreakAchievements(unlocker, stats)
h.checkHourAchievements(unlocker, stats, totalHours)
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) {
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, loc *time.Location) 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
}
loc := loadLocation(user.Timezone)
return h.buildRPGStatus(user.ID, loc), nil
}
// handleRPGCmd handles /rpg — shows stats with an Achievements button.
func (h *Handler) handleRPGCmd(ctx context.Context, msg *models.Message) {
user, err := h.getOrCreateUser(msg.Chat.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading profile")
return
}
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading settings")
return
}
if !st.RPGEnabled {
h.sendText(ctx, msg.Chat.ID, "RPG system is disabled. Enable it in Settings or use /rpgtoggle.")
return
}
loc := loadLocation(user.Timezone)
text := h.buildRPGStatus(user.ID, loc)
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Achievements", CallbackData: "rpg_achievements"}},
{{Text: "Back to Menu", CallbackData: "back_menu"}},
},
}
h.sendWithKB(ctx, msg.Chat.ID, text, kb)
}
// rpgAchievementsCallback shows achievements from the RPG view.
func (h *Handler) rpgAchievementsCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
}
text := h.buildAchievementsList(user.ID)
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Back to RPG", CallbackData: "rpg_back"}},
},
}
h.editText(ctx, chatID, msgID, text, &kb)
}
// rpgBackCallback returns from achievements to the RPG stats view.
func (h *Handler) rpgBackCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
}
loc := loadLocation(user.Timezone)
text := h.buildRPGStatus(user.ID, loc)
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Achievements", CallbackData: "rpg_achievements"}},
{{Text: "Back to Menu", CallbackData: "back_menu"}},
},
}
h.editText(ctx, chatID, msgID, text, &kb)
}
// 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
}