clone

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

Merge pull request #39 from FelineStateMachine/refactor/cleanup-code-structure

FelineStateMachine committed d5eb673
Refactor: split root package into app/, cmd/, and stats/ packages
commit
d5eb67357501649dac704080f12ae36574296755
parent
eb5a9c4a5a48cbe17a41c5f0a381fe6fb7920819
parent
c5a1dc342dc191619d0d8e6cac289190f9370175
author
FelineStateMachine <dami.etoile@gmail.com>
17 files changed, +519 -450
M.goreleaser.yml2+-
MAGENTS.md24++-
Rdebug.go → app/debug.go2+-
Rkeys.go → app/keys.go2+-
Rmodel.go → app/model.go55++++---
Rspawn.go → app/spawn.go8+-
Rupdate.go → app/update.go19+-
Rview.go → app/view.go7+-
Dcmd.go217----------------------------
Acmd/continue.go59++++++++
Acmd/list.go58++++++++
Acmd/new.go82+++++++++++
Acmd/root.go62++++++++
Mjustfile2+-
Mmain.go8+-
Rstats.go → stats/stats.go229+++++++++++++++---------------
Rstats_test.go → stats/stats_test.go133+++++++++---------
M .goreleaser.yml +1 -1
M AGENTS.md +17 -7
@@ -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 ```
R debug.go → app/debug.go +1 -1
R keys.go → app/keys.go +1 -1
R model.go → app/model.go +29 -26
@@ -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)
R spawn.go → app/spawn.go +4 -4
R update.go → app/update.go +10 -9
@@ -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 }
R view.go → app/view.go +4 -3
D cmd.go +0 -217
@@ -1,217 +0,0 @@
1-package main2-3-import (4- "fmt"5- "log"6- "os"7- "strings"8- "text/tabwriter"9-10- "github.com/FelineStateMachine/puzzletea/game"11- "github.com/FelineStateMachine/puzzletea/resolve"12- "github.com/FelineStateMachine/puzzletea/store"13-14- tea "charm.land/bubbletea/v2"15- "github.com/spf13/cobra"16-)17-18-// version is set at build time via ldflags:19-//20-// go build -ldflags "-X main.version=v1.0.0"21-var version = "dev"22-23-var (24- flagNew string25- flagContinue string26-)27-28-var rootCmd = &cobra.Command{29- Use: "puzzletea",30- Version: version,31- Short: "A terminal-based puzzle game framework",32- Long: "PuzzleTea is a terminal-based puzzle game framework featuring Nonogram, Sudoku, Word Search, Hashiwokakero, and Lights Out.",33- RunE: func(cmd *cobra.Command, args []string) error {34- if flagNew != "" {35- parts := strings.SplitN(flagNew, ":", 2)36- gameArg := parts[0]37- var modeArg string38- if len(parts) > 1 {39- modeArg = parts[1]40- }41- return launchNewGame(gameArg, modeArg)42- }43- if flagContinue != "" {44- return continueGame(flagContinue)45- }46- // Default: launch TUI menu.47- s, err := store.Open(store.DefaultDBPath())48- if err != nil {49- return err50- }51- defer s.Close()52- p := tea.NewProgram(initialModel(s))53- _, err = p.Run()54- return err55- },56-}57-58-var newCmd = &cobra.Command{59- Use: "new <game> [mode]",60- Short: "Start a new puzzle game",61- 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 ")),63- Args: cobra.RangeArgs(1, 2),64- RunE: func(cmd *cobra.Command, args []string) error {65- gameArg := args[0]66- var modeArg string67- if len(args) > 1 {68- modeArg = args[1]69- }70- return launchNewGame(gameArg, modeArg)71- },72-}73-74-var continueCmd = &cobra.Command{75- Use: "continue <name>",76- Short: "Resume a saved game by name",77- Long: "Resume a previously saved game using its unique name.\nUse 'puzzletea list' to see available saved games.",78- Args: cobra.ExactArgs(1),79- RunE: func(cmd *cobra.Command, args []string) error {80- return continueGame(args[0])81- },82-}83-84-var listAll bool85-86-var listCmd = &cobra.Command{87- Use: "list",88- Short: "List saved games",89- Long: "Display a table of saved games. Use --all to include abandoned games.",90- RunE: func(cmd *cobra.Command, args []string) error {91- s, err := store.Open(store.DefaultDBPath())92- if err != nil {93- return err94- }95- defer s.Close()96-97- var games []store.GameRecord98- if listAll {99- games, err = s.ListAllGames()100- } else {101- games, err = s.ListGames()102- }103- if err != nil {104- return err105- }106-107- if len(games) == 0 {108- fmt.Println("No saved games.")109- return nil110- }111-112- w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)113- fmt.Fprintln(w, "NAME\tGAME\tMODE\tSTATUS\tLAST UPDATED")114- for _, g := range games {115- fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",116- g.Name,117- g.GameType,118- g.Mode,119- g.Status,120- g.UpdatedAt.Local().Format("Jan 02 15:04"),121- )122- }123- return w.Flush()124- },125-}126-127-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")130-131- listCmd.Flags().BoolVar(&listAll, "all", false, "include abandoned games")132-133- rootCmd.AddCommand(newCmd, continueCmd, listCmd)134-}135-136-// launchNewGame resolves the game/mode, spawns a new game, and launches the TUI.137-func launchNewGame(gameArg, modeArg string) error {138- cat, err := resolve.Category(gameArg, GameCategories)139- if err != nil {140- return err141- }142-143- spawner, modeTitle, err := resolve.Mode(cat, modeArg)144- if err != nil {145- return err146- }147-148- g, err := spawner.Spawn()149- if err != nil {150- return fmt.Errorf("failed to spawn game: %w", err)151- }152-153- s, err := store.Open(store.DefaultDBPath())154- if err != nil {155- return err156- }157- defer s.Close()158-159- name := generateUniqueName(s)160- g = g.SetTitle(name)161-162- initialState, err := g.GetSave()163- if err != nil {164- return fmt.Errorf("failed to get initial save: %w", err)165- }166-167- rec := &store.GameRecord{168- Name: name,169- GameType: cat.Name,170- Mode: modeTitle,171- InitialState: string(initialState),172- SaveState: string(initialState),173- Status: store.StatusNew,174- }175- if err := s.CreateGame(rec); err != nil {176- log.Printf("failed to create game record: %v", err)177- }178-179- m := initialModelWithGame(s, g, rec.ID, false)180- p := tea.NewProgram(m)181- _, err = p.Run()182- return err183-}184-185-// continueGame looks up a saved game by name and launches the TUI.186-func continueGame(name string) error {187- s, err := store.Open(store.DefaultDBPath())188- if err != nil {189- return err190- }191- defer s.Close()192-193- rec, err := s.GetGameByName(name)194- if err != nil {195- return err196- }197- if rec == nil {198- return fmt.Errorf("no saved game found with name %q\nRun 'puzzletea list' to see available games.", name)199- }200-201- importFn, ok := game.Registry[rec.GameType]202- if !ok {203- return fmt.Errorf("unknown game type %q in save data", rec.GameType)204- }205-206- g, err := importFn([]byte(rec.SaveState))207- if err != nil {208- return fmt.Errorf("failed to import game: %w", err)209- }210- g = g.SetTitle(rec.Name)211-212- completed := rec.Status == store.StatusCompleted213- m := initialModelWithGame(s, g, rec.ID, completed)214- p := tea.NewProgram(m)215- _, err = p.Run()216- return err217-}
A cmd/continue.go +59 -0
@@ -0,0 +1,59 @@
1+package cmd2+3+import (4+ "fmt"5+6+ "github.com/FelineStateMachine/puzzletea/app"7+ "github.com/FelineStateMachine/puzzletea/game"8+ "github.com/FelineStateMachine/puzzletea/stats"9+ "github.com/FelineStateMachine/puzzletea/store"10+11+ tea "charm.land/bubbletea/v2"12+ "github.com/spf13/cobra"13+)14+15+var continueCmd = &cobra.Command{16+ Use: "continue <name>",17+ Short: "Resume a saved game by name",18+ Long: "Resume a previously saved game using its unique name.\nUse 'puzzletea list' to see available saved games.",19+ Args: cobra.ExactArgs(1),20+ RunE: func(cmd *cobra.Command, args []string) error {21+ return continueGame(args[0])22+ },23+}24+25+// continueGame looks up a saved game by name and launches the TUI.26+func continueGame(name string) error {27+ s, err := store.Open(store.DefaultDBPath())28+ if err != nil {29+ return err30+ }31+ defer s.Close()32+33+ stats.InitModeXP(app.GameCategories)34+35+ rec, err := s.GetGameByName(name)36+ if err != nil {37+ return err38+ }39+ if rec == nil {40+ return fmt.Errorf("no saved game found with name %q\nRun 'puzzletea list' to see available games.", name)41+ }42+43+ importFn, ok := game.Registry[rec.GameType]44+ if !ok {45+ return fmt.Errorf("unknown game type %q in save data", rec.GameType)46+ }47+48+ g, err := importFn([]byte(rec.SaveState))49+ if err != nil {50+ return fmt.Errorf("failed to import game: %w", err)51+ }52+ g = g.SetTitle(rec.Name)53+54+ completed := rec.Status == store.StatusCompleted55+ m := app.InitialModelWithGame(s, g, rec.ID, completed)56+ p := tea.NewProgram(m)57+ _, err = p.Run()58+ return err59+}
A cmd/list.go +58 -0
A cmd/new.go +82 -0
@@ -0,0 +1,82 @@
1+package cmd2+3+import (4+ "fmt"5+ "log"6+ "strings"7+8+ "github.com/FelineStateMachine/puzzletea/app"9+ "github.com/FelineStateMachine/puzzletea/resolve"10+ "github.com/FelineStateMachine/puzzletea/stats"11+ "github.com/FelineStateMachine/puzzletea/store"12+13+ tea "charm.land/bubbletea/v2"14+ "github.com/spf13/cobra"15+)16+17+var newCmd = &cobra.Command{18+ Use: "new <game> [mode]",19+ Short: "Start a new puzzle game",20+ Long: fmt.Sprintf("Start a new puzzle game, optionally specifying the difficulty mode.\n\nAvailable games:\n %s",21+ strings.Join(resolve.CategoryNames(app.GameCategories), "\n ")),22+ Args: cobra.RangeArgs(1, 2),23+ RunE: func(cmd *cobra.Command, args []string) error {24+ gameArg := args[0]25+ var modeArg string26+ if len(args) > 1 {27+ modeArg = args[1]28+ }29+ return launchNewGame(gameArg, modeArg)30+ },31+}32+33+// launchNewGame resolves the game/mode, spawns a new game, and launches the TUI.34+func launchNewGame(gameArg, modeArg string) error {35+ cat, err := resolve.Category(gameArg, app.GameCategories)36+ if err != nil {37+ return err38+ }39+40+ spawner, modeTitle, err := resolve.Mode(cat, modeArg)41+ if err != nil {42+ return err43+ }44+45+ g, err := spawner.Spawn()46+ if err != nil {47+ return fmt.Errorf("failed to spawn game: %w", err)48+ }49+50+ s, err := store.Open(store.DefaultDBPath())51+ if err != nil {52+ return err53+ }54+ defer s.Close()55+56+ stats.InitModeXP(app.GameCategories)57+58+ name := app.GenerateUniqueName(s)59+ g = g.SetTitle(name)60+61+ initialState, err := g.GetSave()62+ if err != nil {63+ return fmt.Errorf("failed to get initial save: %w", err)64+ }65+66+ rec := &store.GameRecord{67+ Name: name,68+ GameType: cat.Name,69+ Mode: modeTitle,70+ InitialState: string(initialState),71+ SaveState: string(initialState),72+ Status: store.StatusNew,73+ }74+ if err := s.CreateGame(rec); err != nil {75+ log.Printf("failed to create game record: %v", err)76+ }77+78+ m := app.InitialModelWithGame(s, g, rec.ID, false)79+ p := tea.NewProgram(m)80+ _, err = p.Run()81+ return err82+}
A cmd/root.go +62 -0
@@ -0,0 +1,62 @@
1+// Package cmd defines the CLI commands for PuzzleTea using Cobra.2+package cmd3+4+import (5+ "strings"6+7+ "github.com/FelineStateMachine/puzzletea/app"8+ "github.com/FelineStateMachine/puzzletea/stats"9+ "github.com/FelineStateMachine/puzzletea/store"10+11+ tea "charm.land/bubbletea/v2"12+ "github.com/spf13/cobra"13+)14+15+// Version is set at build time via ldflags:16+//17+// go build -ldflags "-X github.com/FelineStateMachine/puzzletea/cmd.Version=v1.0.0"18+var Version = "dev"19+20+var (21+ flagNew string22+ flagContinue string23+)24+25+// RootCmd is the top-level Cobra command.26+var RootCmd = &cobra.Command{27+ Use: "puzzletea",28+ Version: Version,29+ Short: "A terminal-based puzzle game framework",30+ Long: "PuzzleTea is a terminal-based puzzle game framework featuring Nonogram, Sudoku, Word Search, Hashiwokakero, and Lights Out.",31+ RunE: func(cmd *cobra.Command, args []string) error {32+ if flagNew != "" {33+ parts := strings.SplitN(flagNew, ":", 2)34+ gameArg := parts[0]35+ var modeArg string36+ if len(parts) > 1 {37+ modeArg = parts[1]38+ }39+ return launchNewGame(gameArg, modeArg)40+ }41+ if flagContinue != "" {42+ return continueGame(flagContinue)43+ }44+ // Default: launch TUI menu.45+ s, err := store.Open(store.DefaultDBPath())46+ if err != nil {47+ return err48+ }49+ defer s.Close()50+ stats.InitModeXP(app.GameCategories)51+ p := tea.NewProgram(app.InitialModel(s))52+ _, err = p.Run()53+ return err54+ },55+}56+57+func init() {58+ RootCmd.Flags().StringVar(&flagNew, "new", "", "start a new game (game:mode)")59+ RootCmd.Flags().StringVar(&flagContinue, "continue", "", "resume a saved game by name")60+61+ RootCmd.AddCommand(newCmd, continueCmd, listCmd)62+}
M justfile +1 -1
M main.go +6 -2
R stats.go → stats/stats.go +118 -111
@@ -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)
R stats_test.go → stats/stats_test.go +66 -67
@@ -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 }