Files
termdoku/internal/stats/stats.go

185 lines
4.5 KiB
Go

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)
}