185 lines
4.5 KiB
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)
|
|
}
|