(Feat): Initial Commit, Termdoku
This commit is contained in:
184
internal/stats/stats.go
Normal file
184
internal/stats/stats.go
Normal file
@@ -0,0 +1,184 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user