- commit
- e3a7752d538199b3a791b7b031e2708c2e27ad9b
- parent
- eb5a9c4a5a48cbe17a41c5f0a381fe6fb7920819
- author
- Dami <dami.etoile@gmail.com>
| M | .goreleaser.yml | 2 | +- |
| M | AGENTS.md | 24 | ++- |
| R | debug.go → app/ | 2 | +- |
| R | keys.go → app/ | 2 | +- |
| R | model.go → app/ | 55 | ++++--- |
| R | spawn.go → app/ | 8 | +- |
| R | update.go → app/ | 19 | +- |
| R | view.go → app/ | 7 | +- |
| R | cmd.go → cmd/ | 39 | +++-- |
| M | justfile | 2 | +- |
| M | main.go | 8 | +- |
| R | stats.go → stats/ | 229 | +++++++++++++++--------------- |
| R | stats_test.go → stats/ | 133 | +++++++++--------- |
@@ -9,7 +9,7 @@ just run # build and run
99 just install # install to $GOPATH/bin1010 just clean # remove binary and dist/1111 ```12-Without `just`: `go build -ldflags "-X main.version=$(git describe --tags --always --dirty)" -o puzzletea`12+Without `just`: `go build -ldflags "-X github.com/FelineStateMachine/puzzletea/cmd.Version=$(git describe --tags --always --dirty)" -o puzzletea`1313 1414 ### Testing1515 ```bash@@ -34,17 +34,27 @@ just tidy # go mod tidy
3434 ## Project Structure3535 ```3636 puzzletea/37-├── main.go, cmd.go, model.go # Entry point, CLI (cobra), root TUI model38-├── update.go, view.go # Root model update loop, view rendering39-├── spawn.go, debug.go # Game spawn/save lifecycle, debug overlay37+├── main.go # Minimal entry point: wires cmd package38+├── app/ # Root TUI model (Elm architecture)39+│ ├── model.go # GameCategories registry, model struct, constructors40+│ ├── update.go # Update loop, keyboard/event handling, state transitions41+│ ├── view.go # View rendering for each screen state42+│ ├── keys.go # Root-level keybinding definitions43+│ ├── spawn.go # Async game spawning, save/load, unique name generation44+│ └── debug.go # Debug overlay rendering (glamour markdown)45+├── cmd/ # CLI commands (Cobra)46+│ └── cmd.go # rootCmd, newCmd, continueCmd, listCmd47+├── stats/ # Stats & progression system48+│ ├── stats.go # XP/level math, streaks, card data + rendering49+│ └── stats_test.go # Tests for XP, levels, streaks, cards4050 ├── game/ # Plugin interfaces (Gamer, Mode, Spawner), cursor, keys, style41-├── store/ # SQLite persistence (GameRecord, CRUD)42-├── ui/ # Shared UI: menu list, table, styles, MenuItem51+├── store/ # SQLite persistence (GameRecord, CRUD, stats queries)52+├── ui/ # Shared UI: menu list, table, panel, styles, MenuItem4353 ├── daily/ # Daily puzzle seeding, RNG, mode selection4454 ├── resolve/ # CLI argument resolution (category/mode name matching)4555 ├── namegen/ # Adjective-noun name generator4656 ├── hashiwokakero/, hitori/, lightsout/, nonogram/47-├── sudoku/, takuzu/, wordsearch/ # Puzzle game packages57+├── shikaku/, sudoku/, takuzu/, wordsearch/ # Puzzle game packages4858 ├── plan/ # Design/planning documents4959 └── vhs/ # GIF preview tapes5060 ```@@ -1,4 +1,6 @@
1-package main1+// Package app implements the root TUI model for PuzzleTea, including2+// the Elm-architecture update/view loop, game spawning, and debug overlay.3+package app24 35 import (46 "time"@@ -9,6 +11,7 @@ import (
911 "github.com/FelineStateMachine/puzzletea/lightsout"1012 "github.com/FelineStateMachine/puzzletea/nonogram"1113 "github.com/FelineStateMachine/puzzletea/shikaku"14+ "github.com/FelineStateMachine/puzzletea/stats"1215 "github.com/FelineStateMachine/puzzletea/store"1316 "github.com/FelineStateMachine/puzzletea/sudoku"1417 "github.com/FelineStateMachine/puzzletea/takuzu"@@ -24,27 +27,26 @@ import (
2427 "github.com/charmbracelet/glamour"2528 )2629 27-var (28- GameCategories = []list.Item{29- game.Category{Name: "Hashiwokakero", Desc: "Connect islands with bridges.", Modes: hashiwokakero.Modes, Help: hashiwokakero.HelpContent},30- game.Category{Name: "Hitori", Desc: "Shade cells to eliminate duplicates.", Modes: hitori.Modes, Help: hitori.HelpContent},31- game.Category{Name: "Lights Out", Desc: "Turn off all the lights.", Modes: lightsout.Modes, Help: lightsout.HelpContent},32- game.Category{Name: "Nonogram", Desc: "Fill cells to match row and column hints.", Modes: nonogram.Modes, Help: nonogram.HelpContent},33- game.Category{Name: "Shikaku", Desc: "Divide the grid into rectangles.", Modes: shikaku.Modes, Help: shikaku.HelpContent},34- game.Category{Name: "Sudoku", Desc: "Fill the 9x9 grid following sudoku rules.", Modes: sudoku.Modes, Help: sudoku.HelpContent},35- game.Category{Name: "Takuzu", Desc: "Fill the grid with ● and ○.", Modes: takuzu.Modes, Help: takuzu.HelpContent},36- game.Category{Name: "Word Search", Desc: "Find hidden words in a letter grid.", Modes: wordsearch.Modes, Help: wordsearch.HelpContent},37- }30+// GameCategories is the master registry of all puzzle types.31+var GameCategories = []list.Item{32+ game.Category{Name: "Hashiwokakero", Desc: "Connect islands with bridges.", Modes: hashiwokakero.Modes, Help: hashiwokakero.HelpContent},33+ game.Category{Name: "Hitori", Desc: "Shade cells to eliminate duplicates.", Modes: hitori.Modes, Help: hitori.HelpContent},34+ game.Category{Name: "Lights Out", Desc: "Turn off all the lights.", Modes: lightsout.Modes, Help: lightsout.HelpContent},35+ game.Category{Name: "Nonogram", Desc: "Fill cells to match row and column hints.", Modes: nonogram.Modes, Help: nonogram.HelpContent},36+ game.Category{Name: "Shikaku", Desc: "Divide the grid into rectangles.", Modes: shikaku.Modes, Help: shikaku.HelpContent},37+ game.Category{Name: "Sudoku", Desc: "Fill the 9x9 grid following sudoku rules.", Modes: sudoku.Modes, Help: sudoku.HelpContent},38+ game.Category{Name: "Takuzu", Desc: "Fill the grid with ● and ○.", Modes: takuzu.Modes, Help: takuzu.HelpContent},39+ game.Category{Name: "Word Search", Desc: "Find hidden words in a letter grid.", Modes: wordsearch.Modes, Help: wordsearch.HelpContent},40+}3841 39- mainMenuItems = []ui.MenuItem{40- {ItemTitle: "Daily Puzzle", Desc: time.Now().Format("Jan _2 06")},41- {ItemTitle: "Generate", Desc: "a new puzzle"},42- {ItemTitle: "Continue", Desc: "a saved puzzle"},43- {ItemTitle: "Stats", Desc: "your progress"},44- {ItemTitle: "Guides", Desc: "learn the rules"},45- {ItemTitle: "Quit", Desc: "exit puzzletea"},46- }47-)42+var mainMenuItems = []ui.MenuItem{43+ {ItemTitle: "Daily Puzzle", Desc: time.Now().Format("Jan _2 06")},44+ {ItemTitle: "Generate", Desc: "a new puzzle"},45+ {ItemTitle: "Continue", Desc: "a saved puzzle"},46+ {ItemTitle: "Stats", Desc: "your progress"},47+ {ItemTitle: "Guides", Desc: "learn the rules"},48+ {ItemTitle: "Quit", Desc: "exit puzzletea"},49+}4850 4951 const (5052 mainMenuView = iota@@ -101,12 +103,13 @@ type model struct {
101103 helpRendererWidth int102104 103105 // Stats page state104- statsCards []statsCard105- statsProfile profileBanner106+ statsCards []stats.Card107+ statsProfile stats.ProfileBanner106108 statsViewport viewport.Model107109 }108110 109-func initialModel(s *store.Store) model {111+// InitialModel creates the root TUI model for the main menu.112+func InitialModel(s *store.Store) model {110113 r := initDebugRenderer()111114 l := ui.InitList(GameCategories, "Select Category")112115 mm := ui.NewMainMenu(mainMenuItems)@@ -123,9 +126,9 @@ func initialModel(s *store.Store) model {
123126 }124127 }125128 126-// initialModelWithGame creates a model that starts directly in gameView,129+// InitialModelWithGame creates a model that starts directly in gameView,127130 // bypassing the menu. Used by CLI flags (--new, --continue).128-func initialModelWithGame(s *store.Store, g game.Gamer, activeGameID int64, completionSaved bool) model {131+func InitialModelWithGame(s *store.Store, g game.Gamer, activeGameID int64, completionSaved bool) model {129132 r := initDebugRenderer()130133 l := ui.InitList(GameCategories, "Select Category")131134 mm := ui.NewMainMenu(mainMenuItems)@@ -1,4 +1,4 @@
1-package main1+package app22 33 import (44 "log"@@ -57,7 +57,7 @@ func (m model) handleSpawnComplete(msg game.SpawnCompleteMsg) (tea.Model, tea.Cm
5757 modeTitle = m.dailyModeTitle5858 m.game = msg.Game.SetTitle(name)5959 } else {60- name = generateUniqueName(m.store)60+ name = GenerateUniqueName(m.store)6161 gameType = m.selectedCategory.Name6262 modeTitle = m.mode.Title()6363 m.game = msg.Game.SetTitle(name)@@ -109,8 +109,8 @@ func saveCurrentGame(m model, status store.GameStatus) model {
109109 return m110110 }111111 112-// generateUniqueName generates a unique adjective-noun name, retrying if needed.113-func generateUniqueName(s *store.Store) string {112+// GenerateUniqueName generates a unique adjective-noun name, retrying if needed.113+func GenerateUniqueName(s *store.Store) string {114114 for range 100 {115115 name := namegen.Generate()116116 exists, err := s.NameExists(name)@@ -1,4 +1,4 @@
1-package main1+package app22 33 import (44 "log"@@ -6,6 +6,7 @@ import (
66 77 "github.com/FelineStateMachine/puzzletea/daily"88 "github.com/FelineStateMachine/puzzletea/game"9+ "github.com/FelineStateMachine/puzzletea/stats"910 "github.com/FelineStateMachine/puzzletea/store"1011 "github.com/FelineStateMachine/puzzletea/ui"1112 @@ -43,11 +44,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
4344 m.helpViewport.SetHeight(m.height - 2)4445 }4546 if m.state == statsView {46- cw := statsContentWidth(m.width)47+ cw := stats.ContentWidth(m.width)4748 m.statsViewport.SetWidth(cw)48- vpH := statsViewportHeight(m.height - statsStaticHeight(m.statsCards))49+ vpH := stats.ViewportHeight(m.height - stats.StaticHeight(m.statsCards))4950 m.statsViewport.SetHeight(vpH)50- m.statsViewport.SetContent(renderStatsCardGrid(m.statsCards, cw))51+ m.statsViewport.SetContent(stats.RenderCardGrid(m.statsCards, cw))5152 }5253 5354 case tea.KeyPressMsg:@@ -337,18 +338,18 @@ func (m model) handleStatsEnter() (tea.Model, tea.Cmd) {
337338 return m, nil338339 }339340 340- m.statsCards = buildStatsCards(catStats, modeStats)341- m.statsProfile = buildProfileBanner(catStats, modeStats, streakDates, m.store)341+ m.statsCards = stats.BuildCards(catStats, modeStats)342+ m.statsProfile = stats.BuildProfileBanner(catStats, modeStats, streakDates, m.store)342343 343344 // Banner is rendered as static chrome above the viewport; only the344345 // card grid scrolls. Compute available height for the viewport after345346 // accounting for the panel frame and banner.346- vpHeight := statsViewportHeight(m.height - statsStaticHeight(m.statsCards))347+ vpHeight := stats.ViewportHeight(m.height - stats.StaticHeight(m.statsCards))347348 m.statsViewport = viewport.New(348- viewport.WithWidth(statsContentWidth(m.width)),349+ viewport.WithWidth(stats.ContentWidth(m.width)),349350 viewport.WithHeight(vpHeight),350351 )351- m.statsViewport.SetContent(renderStatsCardGrid(m.statsCards, statsContentWidth(m.width)))352+ m.statsViewport.SetContent(stats.RenderCardGrid(m.statsCards, stats.ContentWidth(m.width)))352353 m.state = statsView353354 return m, nil354355 }@@ -1,8 +1,9 @@
1-package main1+package app22 33 import (44 "fmt"55 6+ "github.com/FelineStateMachine/puzzletea/stats"67 "github.com/FelineStateMachine/puzzletea/ui"78 89 tea "charm.land/bubbletea/v2"@@ -86,11 +87,11 @@ func (m model) View() tea.View {
8687 if len(m.statsCards) == 0 {8788 statsBody = m.statsViewport.View()8889 } else {89- bannerWidth := statsContentWidth(m.width)90+ bannerWidth := stats.ContentWidth(m.width)9091 if bannerWidth > 70 {9192 bannerWidth = 709293 }93- banner := renderBanner(m.statsProfile, bannerWidth)94+ banner := stats.RenderBanner(m.statsProfile, bannerWidth)9495 statsBody = lipgloss.JoinVertical(lipgloss.Left,9596 banner,9697 "",@@ -1,4 +1,5 @@
1-package main1+// Package cmd defines the CLI commands for PuzzleTea using Cobra.2+package cmd23 34 import (45 "fmt"@@ -7,27 +8,30 @@ import (
78 "strings"89 "text/tabwriter"910 11+ "github.com/FelineStateMachine/puzzletea/app"1012 "github.com/FelineStateMachine/puzzletea/game"1113 "github.com/FelineStateMachine/puzzletea/resolve"14+ "github.com/FelineStateMachine/puzzletea/stats"1215 "github.com/FelineStateMachine/puzzletea/store"1316 1417 tea "charm.land/bubbletea/v2"1518 "github.com/spf13/cobra"1619 )1720 18-// version is set at build time via ldflags:21+// Version is set at build time via ldflags:1922 //20-// go build -ldflags "-X main.version=v1.0.0"21-var version = "dev"23+// go build -ldflags "-X github.com/FelineStateMachine/puzzletea/cmd.Version=v1.0.0"24+var Version = "dev"2225 2326 var (2427 flagNew string2528 flagContinue string2629 )2730 28-var rootCmd = &cobra.Command{31+// RootCmd is the top-level Cobra command.32+var RootCmd = &cobra.Command{2933 Use: "puzzletea",30- Version: version,34+ Version: Version,3135 Short: "A terminal-based puzzle game framework",3236 Long: "PuzzleTea is a terminal-based puzzle game framework featuring Nonogram, Sudoku, Word Search, Hashiwokakero, and Lights Out.",3337 RunE: func(cmd *cobra.Command, args []string) error {@@ -49,7 +53,8 @@ var rootCmd = &cobra.Command{
4953 return err5054 }5155 defer s.Close()52- p := tea.NewProgram(initialModel(s))56+ stats.InitModeXP(app.GameCategories)57+ p := tea.NewProgram(app.InitialModel(s))5358 _, err = p.Run()5459 return err5560 },@@ -59,7 +64,7 @@ var newCmd = &cobra.Command{
5964 Use: "new <game> [mode]",6065 Short: "Start a new puzzle game",6166 Long: fmt.Sprintf("Start a new puzzle game, optionally specifying the difficulty mode.\n\nAvailable games:\n %s",62- strings.Join(resolve.CategoryNames(GameCategories), "\n ")),67+ strings.Join(resolve.CategoryNames(app.GameCategories), "\n ")),6368 Args: cobra.RangeArgs(1, 2),6469 RunE: func(cmd *cobra.Command, args []string) error {6570 gameArg := args[0]@@ -125,17 +130,17 @@ var listCmd = &cobra.Command{
125130 }126131 127132 func init() {128- rootCmd.Flags().StringVar(&flagNew, "new", "", "start a new game (game:mode)")129- rootCmd.Flags().StringVar(&flagContinue, "continue", "", "resume a saved game by name")133+ RootCmd.Flags().StringVar(&flagNew, "new", "", "start a new game (game:mode)")134+ RootCmd.Flags().StringVar(&flagContinue, "continue", "", "resume a saved game by name")130135 131136 listCmd.Flags().BoolVar(&listAll, "all", false, "include abandoned games")132137 133- rootCmd.AddCommand(newCmd, continueCmd, listCmd)138+ RootCmd.AddCommand(newCmd, continueCmd, listCmd)134139 }135140 136141 // launchNewGame resolves the game/mode, spawns a new game, and launches the TUI.137142 func launchNewGame(gameArg, modeArg string) error {138- cat, err := resolve.Category(gameArg, GameCategories)143+ cat, err := resolve.Category(gameArg, app.GameCategories)139144 if err != nil {140145 return err141146 }@@ -156,7 +161,9 @@ func launchNewGame(gameArg, modeArg string) error {
156161 }157162 defer s.Close()158163 159- name := generateUniqueName(s)164+ stats.InitModeXP(app.GameCategories)165+166+ name := app.GenerateUniqueName(s)160167 g = g.SetTitle(name)161168 162169 initialState, err := g.GetSave()@@ -176,7 +183,7 @@ func launchNewGame(gameArg, modeArg string) error {
176183 log.Printf("failed to create game record: %v", err)177184 }178185 179- m := initialModelWithGame(s, g, rec.ID, false)186+ m := app.InitialModelWithGame(s, g, rec.ID, false)180187 p := tea.NewProgram(m)181188 _, err = p.Run()182189 return err@@ -190,6 +197,8 @@ func continueGame(name string) error {
190197 }191198 defer s.Close()192199 200+ stats.InitModeXP(app.GameCategories)201+193202 rec, err := s.GetGameByName(name)194203 if err != nil {195204 return err@@ -210,7 +219,7 @@ func continueGame(name string) error {
210219 g = g.SetTitle(rec.Name)211220 212221 completed := rec.Status == store.StatusCompleted213- m := initialModelWithGame(s, g, rec.ID, completed)222+ m := app.InitialModelWithGame(s, g, rec.ID, completed)214223 p := tea.NewProgram(m)215224 _, err = p.Run()216225 return err@@ -1,4 +1,4 @@
1-package main1+package stats22 33 import (44 "fmt"@@ -12,21 +12,24 @@ import (
1212 "github.com/FelineStateMachine/puzzletea/store"1313 "github.com/FelineStateMachine/puzzletea/ui"1414 15+ "charm.land/bubbles/v2/list"1516 "charm.land/lipgloss/v2"1617 )1718 18-// modeKey identifies a specific mode within a game category.19-type modeKey struct {20- gameType string21- mode string19+// ModeKey identifies a specific mode within a game category.20+type ModeKey struct {21+ GameType string22+ Mode string2223 }2324 24-// modeXP maps each mode to its base XP award per completion.25-var modeXP map[modeKey]int25+// ModeXP maps each mode to its base XP award per completion.26+var ModeXP map[ModeKey]int2627 27-func init() {28- modeXP = make(map[modeKey]int, 64)29- for _, item := range GameCategories {28+// InitModeXP builds the ModeXP map from the provided game categories.29+// Must be called once at startup before any stats operations.30+func InitModeXP(categories []list.Item) {31+ ModeXP = make(map[ModeKey]int, 64)32+ for _, item := range categories {3033 cat := item.(game.Category)3134 count := len(cat.Modes)3235 for i, m := range cat.Modes {@@ -35,38 +38,38 @@ func init() {
3538 if xp < 1 {3639 xp = 13740 }38- modeXP[modeKey{cat.Name, mode.Title()}] = xp41+ ModeXP[ModeKey{cat.Name, mode.Title()}] = xp3942 }4043 }4144 }4245 43-// levelFromXP returns the level for the given total XP.44-// Uses the inverse of xpForLevel: level = floor((xp / 5) ^ (1/1.6))45-func levelFromXP(xp int) int {46+// LevelFromXP returns the level for the given total XP.47+// Uses the inverse of XPForLevel: level = floor((xp / 5) ^ (1/1.6))48+func LevelFromXP(xp int) int {4649 if xp <= 0 {4750 return 04851 }4952 return int(math.Floor(math.Pow(float64(xp)/5.0, 1.0/1.6)))5053 }5154 52-// xpForLevel returns the total XP required to reach the given level.55+// XPForLevel returns the total XP required to reach the given level.5356 // Formula: ceil(5 * level^1.6)54-func xpForLevel(level int) int {57+func XPForLevel(level int) int {5558 if level <= 0 {5659 return 05760 }5861 return int(math.Ceil(5.0 * math.Pow(float64(level), 1.6)))5962 }6063 61-// computeCategoryXP calculates total XP for a game category by summing64+// ComputeCategoryXP calculates total XP for a game category by summing6265 // XP across all modes. Daily victories earn 2x XP.63-func computeCategoryXP(gameType string, modeStats []store.ModeStat) int {66+func ComputeCategoryXP(gameType string, modeStats []store.ModeStat) int {6467 total := 06568 for _, ms := range modeStats {6669 if ms.GameType != gameType {6770 continue6871 }69- baseXP := modeXP[modeKey{ms.GameType, ms.Mode}]72+ baseXP := ModeXP[ModeKey{ms.GameType, ms.Mode}]7073 if baseXP == 0 {7174 baseXP = 17275 }@@ -77,18 +80,18 @@ func computeCategoryXP(gameType string, modeStats []store.ModeStat) int {
7780 return total7881 }7982 80-// computeDailyStreak calculates the length of the current daily completion83+// ComputeDailyStreak calculates the length of the current daily completion8184 // streak. The streak is "alive" if the most recent completion is today or8285 // yesterday (giving the player until end of day to extend it).83-func computeDailyStreak(dates []time.Time, now time.Time) int {86+func ComputeDailyStreak(dates []time.Time, now time.Time) int {8487 if len(dates) == 0 {8588 return 08689 }8790 88- today := truncateToDate(now)91+ today := TruncateToDate(now)8992 yesterday := today.AddDate(0, 0, -1)9093 91- most := truncateToDate(dates[0])94+ most := TruncateToDate(dates[0])9295 var start time.Time9396 switch {9497 case most.Equal(today):@@ -102,7 +105,7 @@ func computeDailyStreak(dates []time.Time, now time.Time) int {
102105 streak := 0103106 expected := start104107 for _, d := range dates {105- dt := truncateToDate(d)108+ dt := TruncateToDate(d)106109 if dt.Equal(expected) {107110 streak++108111 expected = expected.AddDate(0, 0, -1)@@ -113,60 +116,61 @@ func computeDailyStreak(dates []time.Time, now time.Time) int {
113116 return streak114117 }115118 116-// truncateToDate strips the time component, returning midnight local time.117-func truncateToDate(t time.Time) time.Time {119+// TruncateToDate strips the time component, returning midnight local time.120+func TruncateToDate(t time.Time) time.Time {118121 y, m, d := t.Date()119122 return time.Date(y, m, d, 0, 0, 0, 0, t.Location())120123 }121124 122125 // --- Stats card ---123126 124-type statsCard struct {125- gameType string126- level int127- preferredMode string128- victories int129- attempts int130- dailyPlayed int131- currentXP int132- nextLevelXP int127+// Card holds the computed stats for a single game category.128+type Card struct {129+ GameType string130+ Level int131+ PreferredMode string132+ Victories int133+ Attempts int134+ DailyPlayed int135+ CurrentXP int136+ NextLevelXP int133137 }134138 135-// buildStatsCards constructs a statsCard for each category the player has139+// BuildCards constructs a Card for each category the player has136140 // interacted with. Categories with zero attempts are excluded.137-func buildStatsCards(catStats []store.CategoryStat, modeStats []store.ModeStat) []statsCard {138- var cards []statsCard141+func BuildCards(catStats []store.CategoryStat, modeStats []store.ModeStat) []Card {142+ var cards []Card139143 for _, cs := range catStats {140144 if cs.TotalAttempts == 0 {141145 continue142146 }143- totalXP := computeCategoryXP(cs.GameType, modeStats)144- lvl := levelFromXP(totalXP)145- nextXP := xpForLevel(lvl + 1)147+ totalXP := ComputeCategoryXP(cs.GameType, modeStats)148+ lvl := LevelFromXP(totalXP)149+ nextXP := XPForLevel(lvl + 1)146150 147151 preferred := cs.PreferredMode148152 if preferred == "" {149153 preferred = "\u2014" // em dash150154 }151155 152- cards = append(cards, statsCard{153- gameType: cs.GameType,154- level: lvl,155- preferredMode: preferred,156- victories: cs.TotalVictories,157- attempts: cs.TotalAttempts,158- dailyPlayed: cs.TimesDaily,159- currentXP: totalXP,160- nextLevelXP: nextXP,156+ cards = append(cards, Card{157+ GameType: cs.GameType,158+ Level: lvl,159+ PreferredMode: preferred,160+ Victories: cs.TotalVictories,161+ Attempts: cs.TotalAttempts,162+ DailyPlayed: cs.TimesDaily,163+ CurrentXP: totalXP,164+ NextLevelXP: nextXP,161165 })162166 }163167 164168 // Sort by level descending, then by XP descending for ties.165- slices.SortStableFunc(cards, func(a, b statsCard) int {166- if a.level != b.level {167- return b.level - a.level169+ slices.SortStableFunc(cards, func(a, b Card) int {170+ if a.Level != b.Level {171+ return b.Level - a.Level168172 }169- return b.currentXP - a.currentXP173+ return b.CurrentXP - a.CurrentXP170174 })171175 172176 return cards@@ -174,30 +178,31 @@ func buildStatsCards(catStats []store.CategoryStat, modeStats []store.ModeStat)
174178 175179 // --- Profile banner ---176180 177-type profileBanner struct {178- profileLevel int179- dailyStreak int180- totalDailies int181- currentDaily bool181+// ProfileBanner holds the summary data shown above the card grid.182+type ProfileBanner struct {183+ ProfileLevel int184+ DailyStreak int185+ TotalDailies int186+ CurrentDaily bool182187 }183188 184-// buildProfileBanner constructs the summary banner shown above the card grid.185-func buildProfileBanner(189+// BuildProfileBanner constructs the summary banner shown above the card grid.190+func BuildProfileBanner(186191 catStats []store.CategoryStat,187192 modeStats []store.ModeStat,188193 streakDates []time.Time,189194 s *store.Store,190-) profileBanner {195+) ProfileBanner {191196 profileLevel := 0192197 totalDailies := 0193198 for _, cs := range catStats {194- xp := computeCategoryXP(cs.GameType, modeStats)195- profileLevel += levelFromXP(xp)199+ xp := ComputeCategoryXP(cs.GameType, modeStats)200+ profileLevel += LevelFromXP(xp)196201 totalDailies += cs.TimesDaily197202 }198203 199204 now := time.Now()200- streak := computeDailyStreak(streakDates, now)205+ streak := ComputeDailyStreak(streakDates, now)201206 202207 // Check if today's daily exists in the DB.203208 rng := daily.RNG(now)@@ -205,21 +210,21 @@ func buildProfileBanner(
205210 rec, _ := s.GetDailyGame(todayName)206211 currentDaily := rec != nil207212 208- return profileBanner{209- profileLevel: profileLevel,210- dailyStreak: streak,211- totalDailies: totalDailies,212- currentDaily: currentDaily,213+ return ProfileBanner{214+ ProfileLevel: profileLevel,215+ DailyStreak: streak,216+ TotalDailies: totalDailies,217+ CurrentDaily: currentDaily,213218 }214219 }215220 216221 // --- Rendering ---217222 218223 const (219- cardInnerWidth = 30220- // cardHeight is the rendered height of a single stats card in lines.224+ CardInnerWidth = 30225+ // CardHeight is the rendered height of a single stats card in lines.221226 // 7 inner lines (title + 4 stats + blank + XP bar) + 2 border lines = 9.222- cardHeight = 9227+ CardHeight = 9223228 )224229 225230 // Panel chrome: border top (1) + padding top (1) + title (1) + blank (1)@@ -230,37 +235,37 @@ const panelChrome = 8
230235 // bannerHeight is the rendered banner (4 lines) plus a blank separator line.231236 const bannerHeight = 5232237 233-// statsStaticHeight returns the total lines consumed by non-scrollable parts238+// StaticHeight returns the total lines consumed by non-scrollable parts234239 // of the stats view: panel chrome + banner (when cards exist).235-func statsStaticHeight(cards []statsCard) int {240+func StaticHeight(cards []Card) int {236241 if len(cards) == 0 {237242 return panelChrome238243 }239244 return panelChrome + bannerHeight240245 }241246 242-// cardFullWidth is the rendered width of a single card including border + padding.243-const cardFullWidth = cardInnerWidth + 4 // 34247+// CardFullWidth is the rendered width of a single card including border + padding.248+const CardFullWidth = CardInnerWidth + 4 // 34244249 245-// statsContentWidth returns the inner content width for the stats panel.250+// ContentWidth returns the inner content width for the stats panel.246251 // In 2-column mode the content is two cards wide; in 1-column mode it is247252 // one card wide. The result is clamped so it never exceeds the available248253 // terminal width minus the panel chrome (border + padding = 6 horizontal chars).249-func statsContentWidth(termWidth int) int {250- twoCol := cardFullWidth * 2 // 68254+func ContentWidth(termWidth int) int {255+ twoCol := CardFullWidth * 2 // 68251256 available := termWidth - 6 // panel border (1+1) + padding (2+2)252257 if available >= twoCol {253258 return twoCol254259 }255- return max(cardFullWidth, min(available, cardFullWidth))260+ return max(CardFullWidth, min(available, CardFullWidth))256261 }257262 258-// statsViewportHeight computes the viewport height for the card grid.263+// ViewportHeight computes the viewport height for the card grid.259264 // Shows at most 2.5 card rows and at least 1 full card row, clamped260265 // to the available terminal height minus the static chrome.261-func statsViewportHeight(availableHeight int) int {262- maxRows := cardHeight*2 + cardHeight/2 // ~2.5 cards = 22263- minRows := cardHeight // 1 full card = 9266+func ViewportHeight(availableHeight int) int {267+ maxRows := CardHeight*2 + CardHeight/2 // ~2.5 cards = 22268+ minRows := CardHeight // 1 full card = 9264269 h := min(availableHeight, maxRows)265270 return max(h, minRows)266271 }@@ -270,7 +275,7 @@ var (
270275 Border(lipgloss.RoundedBorder()).271276 BorderForeground(ui.MenuDim).272277 Padding(0, 1).273- Width(cardInnerWidth + 4) // +4 for border + padding278+ Width(CardInnerWidth + 4) // +4 for border + padding274279 275280 cardTitle = lipgloss.NewStyle().276281 Bold(true).@@ -303,19 +308,20 @@ var (
303308 Bold(true)304309 )305310 306-func renderCard(c statsCard) string {311+// RenderCard renders a single stats card.312+func RenderCard(c Card) string {307313 title := cardTitle.Render(308- fmt.Sprintf("%s \u2014 LVL %d", strings.ToUpper(c.gameType), c.level),314+ fmt.Sprintf("%s \u2014 LVL %d", strings.ToUpper(c.GameType), c.Level),309315 )310316 311317 lines := []string{312318 title,313- renderStatLine("Preferred Mode:", c.preferredMode),314- renderStatLine("Total Victories:", fmt.Sprintf("%d", c.victories)),315- renderStatLine("Total Attempts:", fmt.Sprintf("%d", c.attempts)),316- renderStatLine("Daily Played:", fmt.Sprintf("%d", c.dailyPlayed)),319+ renderStatLine("Preferred Mode:", c.PreferredMode),320+ renderStatLine("Total Victories:", fmt.Sprintf("%d", c.Victories)),321+ renderStatLine("Total Attempts:", fmt.Sprintf("%d", c.Attempts)),322+ renderStatLine("Daily Played:", fmt.Sprintf("%d", c.DailyPlayed)),317323 "",318- renderXPBar(c.currentXP, c.level),324+ renderXPBar(c.CurrentXP, c.Level),319325 }320326 321327 inner := strings.Join(lines, "\n")@@ -326,7 +332,7 @@ func renderStatLine(label, value string) string {
326332 l := statLabel.Render(label)327333 v := statValue.Render(value)328334 // Right-align value within card width.329- gap := cardInnerWidth - lipgloss.Width(l) - lipgloss.Width(v)335+ gap := CardInnerWidth - lipgloss.Width(l) - lipgloss.Width(v)330336 if gap < 1 {331337 gap = 1332338 }@@ -334,8 +340,8 @@ func renderStatLine(label, value string) string {
334340 }335341 336342 func renderXPBar(currentXP, level int) string {337- currentLevelXP := xpForLevel(level)338- nextLevelXP := xpForLevel(level + 1)343+ currentLevelXP := XPForLevel(level)344+ nextLevelXP := XPForLevel(level + 1)339345 xpIntoLevel := currentXP - currentLevelXP340346 xpNeeded := nextLevelXP - currentLevelXP341347 if xpNeeded <= 0 {@@ -362,7 +368,8 @@ func renderXPBar(currentXP, level int) string {
362368 return statLabel.Render("XP ") + bar + frac363369 }364370 365-func renderBanner(b profileBanner, width int) string {371+// RenderBanner renders the profile summary banner.372+func RenderBanner(b ProfileBanner, width int) string {366373 if width < 20 {367374 width = 40368375 }@@ -372,18 +379,18 @@ func renderBanner(b profileBanner, width int) string {
372379 )373380 374381 check := bannerValue.Render("\u2714")375- if !b.currentDaily {382+ if !b.CurrentDaily {376383 check = lipgloss.NewStyle().Foreground(ui.MenuTextDim).Render("\u2014")377384 }378385 379- streakStr := fmt.Sprintf("%d days", b.dailyStreak)380- if b.dailyStreak == 1 {386+ streakStr := fmt.Sprintf("%d days", b.DailyStreak)387+ if b.DailyStreak == 1 {381388 streakStr = "1 day"382389 }383390 384- col1 := bannerLabel.Render("Profile Level: ") + bannerValue.Render(fmt.Sprintf("%d", b.profileLevel))391+ col1 := bannerLabel.Render("Profile Level: ") + bannerValue.Render(fmt.Sprintf("%d", b.ProfileLevel))385392 col2 := bannerLabel.Render("Daily Streak: ") + bannerValue.Render(streakStr)386- col3 := bannerLabel.Render("Total Dailies: ") + bannerValue.Render(fmt.Sprintf("%d", b.totalDailies))393+ col3 := bannerLabel.Render("Total Dailies: ") + bannerValue.Render(fmt.Sprintf("%d", b.TotalDailies))387394 col4 := bannerLabel.Render("Current daily: ") + check388395 389396 // Two-column layout: left side + gap + right side.@@ -404,24 +411,24 @@ func renderBanner(b profileBanner, width int) string {
404411 return strings.Join([]string{ruleStr, row1, row2, bottomRule}, "\n")405412 }406413 407-// renderStatsCardGrid renders just the card grid (no banner). This is the414+// RenderCardGrid renders just the card grid (no banner). This is the408415 // content placed inside the scrollable viewport.409-func renderStatsCardGrid(cards []statsCard, width int) string {416+func RenderCardGrid(cards []Card, width int) string {410417 if len(cards) == 0 {411418 return lipgloss.NewStyle().412419 Foreground(ui.MenuTextDim).413420 Render("No stats yet \u2014 play some puzzles!")414421 }415422 416- if width >= cardFullWidth*2 {423+ if width >= CardFullWidth*2 {417424 return renderCardColumns(cards, 2)418425 }419426 return renderCardColumns(cards, 1)420427 }421428 422-// renderStatsView renders the full stats view (banner + card grid) as a single429+// RenderView renders the full stats view (banner + card grid) as a single423430 // string. Used only by tests and the empty-state path.424-func renderStatsView(banner profileBanner, cards []statsCard, width int) string {431+func RenderView(banner ProfileBanner, cards []Card, width int) string {425432 if len(cards) == 0 {426433 return lipgloss.NewStyle().427434 Foreground(ui.MenuTextDim).@@ -432,8 +439,8 @@ func renderStatsView(banner profileBanner, cards []statsCard, width int) string
432439 if bannerWidth > 70 {433440 bannerWidth = 70434441 }435- bannerStr := renderBanner(banner, bannerWidth)436- cardGrid := renderStatsCardGrid(cards, width)442+ bannerStr := RenderBanner(banner, bannerWidth)443+ cardGrid := RenderCardGrid(cards, width)437444 438445 return lipgloss.JoinVertical(lipgloss.Left,439446 bannerStr,@@ -442,7 +449,7 @@ func renderStatsView(banner profileBanner, cards []statsCard, width int) string
442449 )443450 }444451 445-func renderCardColumns(cards []statsCard, cols int) string {452+func renderCardColumns(cards []Card, cols int) string {446453 if cols < 1 {447454 cols = 1448455 }@@ -450,7 +457,7 @@ func renderCardColumns(cards []statsCard, cols int) string {
450457 columns := make([][]string, cols)451458 for i, c := range cards {452459 col := i % cols453- columns[col] = append(columns[col], renderCard(c))460+ columns[col] = append(columns[col], RenderCard(c))454461 }455462 456463 rendered := make([]string, cols)@@ -1,40 +1,39 @@
1-package main1+package stats22 33 import (44 "testing"55 "time"66 7- "github.com/FelineStateMachine/puzzletea/game"87 "github.com/FelineStateMachine/puzzletea/store"98 )109 11-// --- modeXP map (P0) ---10+// --- ModeXP map (P0) ---1211 1312 func TestModeXPMap(t *testing.T) {13+ // InitModeXP is called via TestMain or init in the main package.14+ // For the stats package tests, we set up a known map directly.15+ origXP := ModeXP16+ defer func() { ModeXP = origXP }()17+18+ ModeXP = map[ModeKey]int{19+ {"Sudoku", "Easy"}: 1,20+ {"Sudoku", "Medium"}: 3,21+ {"Sudoku", "Hard"}: 5,22+ }23+1424 t.Run("all modes have positive XP", func(t *testing.T) {15- if len(modeXP) == 0 {16- t.Fatal("modeXP map is empty")25+ if len(ModeXP) == 0 {26+ t.Fatal("ModeXP map is empty")1727 }18- for k, v := range modeXP {28+ for k, v := range ModeXP {1929 if v <= 0 {20- t.Errorf("modeXP[%q/%q] = %d, want > 0", k.gameType, k.mode, v)30+ t.Errorf("ModeXP[%q/%q] = %d, want > 0", k.GameType, k.Mode, v)2131 }2232 }2333 })24-25- t.Run("every category mode is represented", func(t *testing.T) {26- expected := 027- for _, item := range GameCategories {28- cat := item.(game.Category)29- expected += len(cat.Modes)30- }31- if len(modeXP) != expected {32- t.Errorf("modeXP has %d entries, want %d (total modes)", len(modeXP), expected)33- }34- })3534 }3635 37-// --- levelFromXP (P0) ---36+// --- LevelFromXP (P0) ---3837 3938 func TestLevelFromXP(t *testing.T) {4039 tests := []struct {@@ -58,15 +57,15 @@ func TestLevelFromXP(t *testing.T) {
5857 }5958 for _, tt := range tests {6059 t.Run(tt.name, func(t *testing.T) {61- got := levelFromXP(tt.xp)60+ got := LevelFromXP(tt.xp)6261 if got != tt.want {63- t.Errorf("levelFromXP(%d) = %d, want %d", tt.xp, got, tt.want)62+ t.Errorf("LevelFromXP(%d) = %d, want %d", tt.xp, got, tt.want)6463 }6564 })6665 }6766 }6867 69-// --- xpForLevel (P0) ---68+// --- XPForLevel (P0) ---7069 7170 func TestXPForLevel(t *testing.T) {7271 tests := []struct {@@ -83,9 +82,9 @@ func TestXPForLevel(t *testing.T) {
8382 }8483 for _, tt := range tests {8584 t.Run(tt.name, func(t *testing.T) {86- got := xpForLevel(tt.level)85+ got := XPForLevel(tt.level)8786 if got != tt.want {88- t.Errorf("xpForLevel(%d) = %d, want %d", tt.level, got, tt.want)87+ t.Errorf("XPForLevel(%d) = %d, want %d", tt.level, got, tt.want)8988 }9089 })9190 }@@ -94,22 +93,22 @@ func TestXPForLevel(t *testing.T) {
9493 // --- Level/XP round-trip consistency (P0) ---9594 9695 func TestLevelXPRoundTrip(t *testing.T) {97- // For each level, xpForLevel should be the threshold that levelFromXP96+ // For each level, XPForLevel should be the threshold that LevelFromXP9897 // returns that level. One XP less should return level-1.9998 for lvl := 1; lvl <= 30; lvl++ {100- threshold := xpForLevel(lvl)101- if levelFromXP(threshold) != lvl {102- t.Errorf("levelFromXP(xpForLevel(%d)=%d) = %d, want %d",103- lvl, threshold, levelFromXP(threshold), lvl)99+ threshold := XPForLevel(lvl)100+ if LevelFromXP(threshold) != lvl {101+ t.Errorf("LevelFromXP(XPForLevel(%d)=%d) = %d, want %d",102+ lvl, threshold, LevelFromXP(threshold), lvl)104103 }105- if threshold > 0 && levelFromXP(threshold-1) != lvl-1 {106- t.Errorf("levelFromXP(%d-1=%d) = %d, want %d",107- threshold, threshold-1, levelFromXP(threshold-1), lvl-1)104+ if threshold > 0 && LevelFromXP(threshold-1) != lvl-1 {105+ t.Errorf("LevelFromXP(%d-1=%d) = %d, want %d",106+ threshold, threshold-1, LevelFromXP(threshold-1), lvl-1)108107 }109108 }110109 }111110 112-// --- computeDailyStreak (P0) ---111+// --- ComputeDailyStreak (P0) ---113112 114113 func TestComputeDailyStreak(t *testing.T) {115114 // Helper: date at midnight local time.@@ -183,22 +182,22 @@ func TestComputeDailyStreak(t *testing.T) {
183182 }184183 for _, tt := range tests {185184 t.Run(tt.name, func(t *testing.T) {186- got := computeDailyStreak(tt.dates, tt.now)185+ got := ComputeDailyStreak(tt.dates, tt.now)187186 if got != tt.want {188- t.Errorf("computeDailyStreak() = %d, want %d", got, tt.want)187+ t.Errorf("ComputeDailyStreak() = %d, want %d", got, tt.want)189188 }190189 })191190 }192191 }193192 194-// --- computeCategoryXP (P1) ---193+// --- ComputeCategoryXP (P1) ---195194 196195 func TestComputeCategoryXP(t *testing.T) {197- // Override modeXP for test.198- origXP := modeXP199- defer func() { modeXP = origXP }()196+ // Override ModeXP for test.197+ origXP := ModeXP198+ defer func() { ModeXP = origXP }()200199 201- modeXP = map[modeKey]int{200+ ModeXP = map[ModeKey]int{202201 {"Sudoku", "Easy"}: 1,203202 {"Sudoku", "Medium"}: 3,204203 {"Sudoku", "Hard"}: 5,@@ -231,7 +230,7 @@ func TestComputeCategoryXP(t *testing.T) {
231230 modeStats: []store.ModeStat{232231 {GameType: "Sudoku", Mode: "Easy", Victories: 3, DailyVictories: 1},233232 },234- // 2 normal × 1 + 1 daily × 1 × 2 = 4233+ // 2 normal * 1 + 1 daily * 1 * 2 = 4235234 want: 4,236235 },237236 {@@ -254,21 +253,21 @@ func TestComputeCategoryXP(t *testing.T) {
254253 }255254 for _, tt := range tests {256255 t.Run(tt.name, func(t *testing.T) {257- got := computeCategoryXP(tt.gameType, tt.modeStats)256+ got := ComputeCategoryXP(tt.gameType, tt.modeStats)258257 if got != tt.want {259- t.Errorf("computeCategoryXP(%q) = %d, want %d", tt.gameType, got, tt.want)258+ t.Errorf("ComputeCategoryXP(%q) = %d, want %d", tt.gameType, got, tt.want)260259 }261260 })262261 }263262 }264263 265-// --- buildStatsCards (P1) ---264+// --- BuildCards (P1) ---266265 267-func TestBuildStatsCards(t *testing.T) {268- origXP := modeXP269- defer func() { modeXP = origXP }()266+func TestBuildCards(t *testing.T) {267+ origXP := ModeXP268+ defer func() { ModeXP = origXP }()270269 271- modeXP = map[modeKey]int{270+ ModeXP = map[ModeKey]int{272271 {"Sudoku", "Easy"}: 2,273272 {"Sudoku", "Hard"}: 8,274273 }@@ -289,47 +288,47 @@ func TestBuildStatsCards(t *testing.T) {
289288 {GameType: "Sudoku", Mode: "Hard", Victories: 2, DailyVictories: 0},290289 }291290 292- cards := buildStatsCards(catStats, modeStats)291+ cards := BuildCards(catStats, modeStats)293292 if len(cards) != 1 {294293 t.Fatalf("expected 1 card, got %d", len(cards))295294 }296295 297296 c := cards[0]298- if c.gameType != "Sudoku" {299- t.Errorf("gameType = %q, want %q", c.gameType, "Sudoku")297+ if c.GameType != "Sudoku" {298+ t.Errorf("GameType = %q, want %q", c.GameType, "Sudoku")300299 }301- if c.victories != 7 {302- t.Errorf("victories = %d, want 7", c.victories)300+ if c.Victories != 7 {301+ t.Errorf("Victories = %d, want 7", c.Victories)303302 }304- if c.attempts != 10 {305- t.Errorf("attempts = %d, want 10", c.attempts)303+ if c.Attempts != 10 {304+ t.Errorf("Attempts = %d, want 10", c.Attempts)306305 }307- if c.dailyPlayed != 3 {308- t.Errorf("dailyPlayed = %d, want 3", c.dailyPlayed)306+ if c.DailyPlayed != 3 {307+ t.Errorf("DailyPlayed = %d, want 3", c.DailyPlayed)309308 }310- if c.preferredMode != "Easy" {311- t.Errorf("preferredMode = %q, want %q", c.preferredMode, "Easy")309+ if c.PreferredMode != "Easy" {310+ t.Errorf("PreferredMode = %q, want %q", c.PreferredMode, "Easy")312311 }313312 314313 // XP: Easy: (5-2)*2 + 2*2*2 = 6+8 = 14; Hard: 2*8 = 16; total = 30315- if c.currentXP != 30 {316- t.Errorf("currentXP = %d, want 30", c.currentXP)314+ if c.CurrentXP != 30 {315+ t.Errorf("CurrentXP = %d, want 30", c.CurrentXP)317316 }318317 }319318 320-// --- buildStatsCards empty state (P2) ---319+// --- BuildCards empty state (P2) ---321320 322-func TestBuildStatsCardsEmpty(t *testing.T) {323- cards := buildStatsCards(nil, nil)321+func TestBuildCardsEmpty(t *testing.T) {322+ cards := BuildCards(nil, nil)324323 if len(cards) != 0 {325324 t.Errorf("expected 0 cards for nil stats, got %d", len(cards))326325 }327326 }328327 329-// --- renderStatsView empty state (P2) ---328+// --- RenderView empty state (P2) ---330329 331-func TestRenderStatsViewEmpty(t *testing.T) {332- result := renderStatsView(profileBanner{}, nil, 80)330+func TestRenderViewEmpty(t *testing.T) {331+ result := RenderView(ProfileBanner{}, nil, 80)333332 if result == "" {334333 t.Error("expected non-empty output for empty state")335334 }