clone

fifteen puzzle games for the terminal, with daily puzzles, weekly ladders and xp, built on bubble tea

main
puzzletea / stats / stats.go
stats.go 6.0K
1package stats23import (4 "math"5 "slices"6 "time"78 "github.com/FelineStateMachine/puzzletea/difficulty"9 "github.com/FelineStateMachine/puzzletea/puzzle"10 "github.com/FelineStateMachine/puzzletea/store"11)1213// ModeKey identifies a specific mode within a game category.14type ModeKey struct {15 GameType string16 Mode string17}1819type Weights map[ModeKey]int2021func WeightsFromDefinitions(definitions []puzzle.Definition) Weights {22 weights := make(Weights, 64)23 for _, def := range definitions {24 count := len(def.Variants)25 if count > 0 {26 for i, variant := range def.Variants {27 xp := int(math.Round(float64(i) / float64(count) * 10))28 xp = max(xp, 1)29 weights[ModeKey{GameType: def.Name, Mode: variant.Title}] = xp30 }31 }32 for _, alias := range def.LegacyModes {33 weights[ModeKey{GameType: def.Name, Mode: alias.Title}] = max(alias.XPWeight, 1)34 }35 for _, mode := range def.Modes {36 if _, ok := weights[ModeKey{GameType: def.Name, Mode: mode.Title}]; !ok {37 weights[ModeKey{GameType: def.Name, Mode: mode.Title}] = legacyModeXPWeight(mode, def.Modes)38 }39 }40 }41 return weights42}4344func legacyModeXPWeight(mode puzzle.ModeDef, modes []puzzle.ModeDef) int {45 count := len(modes)46 if count == 0 {47 return 148 }49 for i, candidate := range modes {50 if candidate.ID != mode.ID {51 continue52 }53 xp := int(math.Round(float64(i) / float64(count) * 10))54 return max(xp, 1)55 }56 return 157}5859// LevelFromXP returns the level for the given total XP.60// Uses the inverse of XPForLevel: level = floor((xp / 5) ^ (1/1.6))61func LevelFromXP(xp int) int {62 if xp <= 0 {63 return 064 }65 return int(math.Floor(math.Pow(float64(xp)/5.0, 1.0/1.6)))66}6768// XPForLevel returns the total XP required to reach the given level.69// Formula: ceil(5 * level^1.6)70func XPForLevel(level int) int {71 if level <= 0 {72 return 073 }74 return int(math.Ceil(5.0 * math.Pow(float64(level), 1.6)))75}7677// ComputeCategoryXP calculates total XP for a game category by summing78// XP across all modes. Daily victories earn 2x XP.79func ComputeCategoryXP(weights Weights, gameType string, modeStats []store.ModeStat) int {80 total := 081 for _, ms := range modeStats {82 if ms.GameType != gameType {83 continue84 }85 baseXP := modeStatXP(weights, ms)86 normalVictories := ms.Victories - ms.DailyVictories87 total += normalVictories * baseXP88 total += ms.DailyVictories * baseXP * 289 total += ms.WeeklyBonusXP90 }91 return total92}9394func modeStatXP(weights Weights, ms store.ModeStat) int {95 if ms.DifficultyElo != nil {96 score := difficulty.Score01(difficulty.Elo(*ms.DifficultyElo))97 return max(1, int(math.Round(score*10)))98 }99 baseXP := weights[ModeKey{ms.GameType, ms.Mode}]100 if baseXP == 0 {101 return 1102 }103 return baseXP104}105106// ComputeDailyStreak calculates the length of the current daily completion107// streak. The streak is "alive" if the most recent completion is today or108// yesterday (giving the player until end of day to extend it).109func ComputeDailyStreak(dates []time.Time, now time.Time) int {110 if len(dates) == 0 {111 return 0112 }113114 today := TruncateToDate(now)115 yesterday := today.AddDate(0, 0, -1)116117 most := TruncateToDate(dates[0])118 var start time.Time119 switch {120 case most.Equal(today):121 start = today122 case most.Equal(yesterday):123 start = yesterday124 default:125 return 0126 }127128 streak := 0129 expected := start130 for _, d := range dates {131 dt := TruncateToDate(d)132 if dt.Equal(expected) {133 streak++134 expected = expected.AddDate(0, 0, -1)135 } else if dt.Before(expected) {136 break137 }138 }139 return streak140}141142// TruncateToDate strips the time component, returning midnight local time.143func TruncateToDate(t time.Time) time.Time {144 y, m, d := t.Date()145 return time.Date(y, m, d, 0, 0, 0, 0, t.Location())146}147148// --- Stats card ---149150// Card holds the computed stats for a single game category.151type Card struct {152 GameType string153 Level int154 PreferredMode string155 Victories int156 Attempts int157 DailyPlayed int158 CurrentXP int159 NextLevelXP int160}161162// BuildCards constructs a Card for each category the player has163// interacted with. Categories with zero attempts are excluded.164func BuildCards(weights Weights, catStats []store.CategoryStat, modeStats []store.ModeStat) []Card {165 var cards []Card166 for _, cs := range catStats {167 if cs.TotalAttempts == 0 {168 continue169 }170 totalXP := ComputeCategoryXP(weights, cs.GameType, modeStats)171 lvl := LevelFromXP(totalXP)172 nextXP := XPForLevel(lvl + 1)173174 preferred := cs.PreferredMode175 if preferred == "" {176 preferred = "\u2014" // em dash177 }178179 cards = append(cards, Card{180 GameType: cs.GameType,181 Level: lvl,182 PreferredMode: preferred,183 Victories: cs.TotalVictories,184 Attempts: cs.TotalAttempts,185 DailyPlayed: cs.TimesDaily,186 CurrentXP: totalXP,187 NextLevelXP: nextXP,188 })189 }190191 // Sort by level descending, then by XP descending for ties.192 slices.SortStableFunc(cards, func(a, b Card) int {193 if a.Level != b.Level {194 return b.Level - a.Level195 }196 return b.CurrentXP - a.CurrentXP197 })198199 return cards200}201202// --- Profile banner ---203204// ProfileBanner holds the summary data shown above the card grid.205type ProfileBanner struct {206 ProfileLevel int207 DailyStreak int208 TotalDailies int209 CurrentDaily bool210 WeekliesCompleted int211 ThisWeekHighestIndex int212}213214// BuildProfileBanner constructs the summary banner shown above the card grid.215func BuildProfileBanner(216 catStats []store.CategoryStat,217 modeStats []store.ModeStat,218 weights Weights,219 streakDates []time.Time,220 currentDaily bool,221 weekliesCompleted int,222 thisWeekHighestIndex int,223) ProfileBanner {224 profileLevel := 0225 totalDailies := 0226 for _, cs := range catStats {227 xp := ComputeCategoryXP(weights, cs.GameType, modeStats)228 profileLevel += LevelFromXP(xp)229 totalDailies += cs.TimesDaily230 }231232 streak := ComputeDailyStreak(streakDates, time.Now())233234 return ProfileBanner{235 ProfileLevel: profileLevel,236 DailyStreak: streak,237 TotalDailies: totalDailies,238 CurrentDaily: currentDaily,239 WeekliesCompleted: weekliesCompleted,240 ThisWeekHighestIndex: thisWeekHighestIndex,241 }242}