package stats import ( "encoding/json" "errors" "fmt" "io/fs" "os" "path/filepath" "sort" "time" ) // Stats tracks player statistics and achievements type Stats struct { TotalGames int `json:"totalGames"` CompletedGames int `json:"completedGames"` CurrentStreak int `json:"currentStreak"` BestStreak int `json:"bestStreak"` LastPlayedDate string `json:"lastPlayedDate"` // YYYY-MM-DD format BestTimes map[string]int `json:"bestTimes"` // difficulty -> seconds CompletionCounts map[string]int `json:"completionCounts"` HintsUsed int `json:"hintsUsed"` RecentGames []GameRecord `json:"recentGames"` DailyHistory map[string]GameRecord `json:"dailyHistory"` // date -> game record } // GameRecord represents a completed game type GameRecord struct { Difficulty string `json:"difficulty"` Completed bool `json:"completed"` Time int `json:"time"` // seconds HintsUsed int `json:"hintsUsed"` Date time.Time `json:"date"` IsDaily bool `json:"isDaily"` DailySeed string `json:"dailySeed,omitempty"` } // Default returns a new Stats with zero values func Default() Stats { return Stats{ BestTimes: make(map[string]int), CompletionCounts: make(map[string]int), RecentGames: []GameRecord{}, DailyHistory: make(map[string]GameRecord), } } // path returns the stats file path func path() (string, error) { h, err := os.UserHomeDir() if err != nil { return "", err } return filepath.Join(h, ".termdoku", "stats.json"), nil } // Load reads stats from disk func Load() (Stats, error) { st := Default() p, err := path() if err != nil { return st, err } b, err := os.ReadFile(p) if err != nil { if errors.Is(err, fs.ErrNotExist) { return st, nil } return st, err } if err := json.Unmarshal(b, &st); err != nil { return st, err } return st, nil } // Save writes stats to disk func Save(st Stats) error { p, err := path() if err != nil { return err } if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { return err } data, err := json.MarshalIndent(st, "", " ") if err != nil { return err } return os.WriteFile(p, data, 0o644) } // RecordGame records a completed or abandoned game func (s *Stats) RecordGame(record GameRecord) { s.TotalGames++ if record.Completed { s.CompletedGames++ today := time.Now().Format("2006-01-02") if s.LastPlayedDate == "" { s.CurrentStreak = 1 } else { lastDate, _ := time.Parse("2006-01-02", s.LastPlayedDate) daysDiff := int(time.Since(lastDate).Hours() / 24) switch daysDiff { case 0: // Same day, maintain streak case 1: // Consecutive day s.CurrentStreak++ default: // Streak broken s.CurrentStreak = 1 } } s.LastPlayedDate = today if s.CurrentStreak > s.BestStreak { s.BestStreak = s.CurrentStreak } if record.Time > 0 { if existing, ok := s.BestTimes[record.Difficulty]; !ok || record.Time < existing { s.BestTimes[record.Difficulty] = record.Time } } s.CompletionCounts[record.Difficulty]++ if record.IsDaily && record.DailySeed != "" { s.DailyHistory[record.DailySeed] = record } } // Track hints s.HintsUsed += record.HintsUsed // Add to recent games (keep last 50) s.RecentGames = append([]GameRecord{record}, s.RecentGames...) if len(s.RecentGames) > 50 { s.RecentGames = s.RecentGames[:50] } } // GetLeaderboard returns top N game records by time for a difficulty func (s *Stats) GetLeaderboard(difficulty string, limit int) []GameRecord { var records []GameRecord for _, game := range s.RecentGames { if game.Difficulty == difficulty && game.Completed && game.Time > 0 { records = append(records, game) } } // Sort by time (ascending) sort.Slice(records, func(i, j int) bool { return records[i].Time < records[j].Time }) if len(records) > limit { records = records[:limit] } return records } // HasPlayedDaily checks if the user has played today's daily func (s *Stats) HasPlayedDaily(seed string) bool { _, ok := s.DailyHistory[seed] return ok } // GetDailyRecord returns the record for a specific daily seed func (s *Stats) GetDailyRecord(seed string) (GameRecord, bool) { record, ok := s.DailyHistory[seed] return record, ok } // FormatTime formats seconds into MM:SS func FormatTime(seconds int) string { mins := seconds / 60 secs := seconds % 60 return fmt.Sprintf("%02d:%02d", mins, secs) }