1package stats23import (4 "math"5 "slices"6 "time"78 "github.com/FelineStateMachine/puzzletea/puzzle"9 "github.com/FelineStateMachine/puzzletea/store"10)1112// ModeKey identifies a specific mode within a game category.13type ModeKey struct {14 GameType string15 Mode string16}1718type Weights map[ModeKey]int1920func WeightsFromDefinitions(definitions []puzzle.Definition) Weights {21 weights := make(Weights, 64)22 for _, def := range definitions {23 count := len(def.Modes)24 for i, mode := range def.Modes {25 xp := int(math.Round(float64(i) / float64(count) * 10))26 xp = max(xp, 1)27 weights[ModeKey{GameType: def.Name, Mode: mode.Title}] = xp28 }29 }30 return weights31}3233// LevelFromXP returns the level for the given total XP.34// Uses the inverse of XPForLevel: level = floor((xp / 5) ^ (1/1.6))35func LevelFromXP(xp int) int {36 if xp <= 0 {37 return 038 }39 return int(math.Floor(math.Pow(float64(xp)/5.0, 1.0/1.6)))40}4142// XPForLevel returns the total XP required to reach the given level.43// Formula: ceil(5 * level^1.6)44func XPForLevel(level int) int {45 if level <= 0 {46 return 047 }48 return int(math.Ceil(5.0 * math.Pow(float64(level), 1.6)))49}5051// ComputeCategoryXP calculates total XP for a game category by summing52// XP across all modes. Daily victories earn 2x XP.53func ComputeCategoryXP(weights Weights, gameType string, modeStats []store.ModeStat) int {54 total := 055 for _, ms := range modeStats {56 if ms.GameType != gameType {57 continue58 }59 baseXP := weights[ModeKey{ms.GameType, ms.Mode}]60 if baseXP == 0 {61 baseXP = 162 }63 normalVictories := ms.Victories - ms.DailyVictories64 total += normalVictories * baseXP65 total += ms.DailyVictories * baseXP * 266 total += ms.WeeklyBonusXP67 }68 return total69}7071// ComputeDailyStreak calculates the length of the current daily completion72// streak. The streak is "alive" if the most recent completion is today or73// yesterday (giving the player until end of day to extend it).74func ComputeDailyStreak(dates []time.Time, now time.Time) int {75 if len(dates) == 0 {76 return 077 }7879 today := TruncateToDate(now)80 yesterday := today.AddDate(0, 0, -1)8182 most := TruncateToDate(dates[0])83 var start time.Time84 switch {85 case most.Equal(today):86 start = today87 case most.Equal(yesterday):88 start = yesterday89 default:90 return 091 }9293 streak := 094 expected := start95 for _, d := range dates {96 dt := TruncateToDate(d)97 if dt.Equal(expected) {98 streak++99 expected = expected.AddDate(0, 0, -1)100 } else if dt.Before(expected) {101 break102 }103 }104 return streak105}106107// TruncateToDate strips the time component, returning midnight local time.108func TruncateToDate(t time.Time) time.Time {109 y, m, d := t.Date()110 return time.Date(y, m, d, 0, 0, 0, 0, t.Location())111}112113// --- Stats card ---114115// Card holds the computed stats for a single game category.116type Card struct {117 GameType string118 Level int119 PreferredMode string120 Victories int121 Attempts int122 DailyPlayed int123 CurrentXP int124 NextLevelXP int125}126127// BuildCards constructs a Card for each category the player has128// interacted with. Categories with zero attempts are excluded.129func BuildCards(weights Weights, catStats []store.CategoryStat, modeStats []store.ModeStat) []Card {130 var cards []Card131 for _, cs := range catStats {132 if cs.TotalAttempts == 0 {133 continue134 }135 totalXP := ComputeCategoryXP(weights, cs.GameType, modeStats)136 lvl := LevelFromXP(totalXP)137 nextXP := XPForLevel(lvl + 1)138139 preferred := cs.PreferredMode140 if preferred == "" {141 preferred = "\u2014" // em dash142 }143144 cards = append(cards, Card{145 GameType: cs.GameType,146 Level: lvl,147 PreferredMode: preferred,148 Victories: cs.TotalVictories,149 Attempts: cs.TotalAttempts,150 DailyPlayed: cs.TimesDaily,151 CurrentXP: totalXP,152 NextLevelXP: nextXP,153 })154 }155156 // Sort by level descending, then by XP descending for ties.157 slices.SortStableFunc(cards, func(a, b Card) int {158 if a.Level != b.Level {159 return b.Level - a.Level160 }161 return b.CurrentXP - a.CurrentXP162 })163164 return cards165}166167// --- Profile banner ---168169// ProfileBanner holds the summary data shown above the card grid.170type ProfileBanner struct {171 ProfileLevel int172 DailyStreak int173 TotalDailies int174 CurrentDaily bool175 WeekliesCompleted int176 ThisWeekHighestIndex int177}178179// BuildProfileBanner constructs the summary banner shown above the card grid.180func BuildProfileBanner(181 catStats []store.CategoryStat,182 modeStats []store.ModeStat,183 weights Weights,184 streakDates []time.Time,185 currentDaily bool,186 weekliesCompleted int,187 thisWeekHighestIndex int,188) ProfileBanner {189 profileLevel := 0190 totalDailies := 0191 for _, cs := range catStats {192 xp := ComputeCategoryXP(weights, cs.GameType, modeStats)193 profileLevel += LevelFromXP(xp)194 totalDailies += cs.TimesDaily195 }196197 streak := ComputeDailyStreak(streakDates, time.Now())198199 return ProfileBanner{200 ProfileLevel: profileLevel,201 DailyStreak: streak,202 TotalDailies: totalDailies,203 CurrentDaily: currentDaily,204 WeekliesCompleted: weekliesCompleted,205 ThisWeekHighestIndex: thisWeekHighestIndex,206 }207}