- commit
- a470cdf7f6047fea16ec34ed067e28c5cbe6c449
- parent
- f55f9d04c07b4833b69a9d4d7cd558f5a7b79c4a
- author
- Dami <dami@Damis-MacBook-Air.local>
| M | go.mod | 18 | +++- |
| M | main.go | 9 | ++- |
| M | model.go | 114 | ++++++++++++++++++++++++++++- |
| A | namegen/ | 62 | +++++++++++++++ |
| A | store/ | 25 | ++++++ |
| A | store/ | 121 | ++++++++++++++++++++++++++++++ |
| M | sudoku/ | 46 | +++++++++++- |
@@ -2,7 +2,10 @@ module github.com/FelineStateMachine/puzzletea
22 33 go 1.24.544 5-require github.com/charmbracelet/bubbletea v1.3.65+require (6+ github.com/charmbracelet/bubbletea v1.3.67+ modernc.org/sqlite v1.44.38+)69 710 require (811 github.com/alecthomas/chroma/v2 v2.14.0 // indirect@@ -10,14 +13,21 @@ require (
1013 github.com/aymerick/douceur v0.2.0 // indirect1114 github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect1215 github.com/dlclark/regexp2 v1.11.0 // indirect16+ github.com/dustin/go-humanize v1.0.1 // indirect17+ github.com/google/uuid v1.6.0 // indirect1318 github.com/gorilla/css v1.0.1 // indirect1419 github.com/microcosm-cc/bluemonday v1.0.27 // indirect1520 github.com/muesli/reflow v0.3.0 // indirect21+ github.com/ncruces/go-strftime v1.0.0 // indirect22+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect1623 github.com/sahilm/fuzzy v0.1.1 // indirect1724 github.com/yuin/goldmark v1.7.8 // indirect1825 github.com/yuin/goldmark-emoji v1.0.5 // indirect1926 golang.org/x/net v0.33.0 // indirect2027 golang.org/x/term v0.31.0 // indirect28+ modernc.org/libc v1.67.6 // indirect29+ modernc.org/mathutil v1.7.1 // indirect30+ modernc.org/memory v1.11.0 // indirect2131 )2232 2333 require (@@ -39,8 +49,8 @@ require (
3949 github.com/muesli/termenv v0.16.0 // indirect4050 github.com/rivo/uniseg v0.4.7 // indirect4151 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect42- golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect43- golang.org/x/sync v0.15.0 // indirect44- golang.org/x/sys v0.33.0 // indirect52+ golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect53+ golang.org/x/sync v0.17.0 // indirect54+ golang.org/x/sys v0.37.0 // indirect4555 golang.org/x/text v0.24.0 // indirect4656 )@@ -3,11 +3,18 @@ package main
33 import (44 "log"55 6+ "github.com/FelineStateMachine/puzzletea/store"67 tea "github.com/charmbracelet/bubbletea"78 )89 910 func main() {10- p := tea.NewProgram(initialModel())11+ s, err := store.Open(store.DefaultDBPath())12+ if err != nil {13+ log.Fatal(err)14+ }15+ defer s.Close()16+17+ p := tea.NewProgram(initialModel(s))1118 if _, err := p.Run(); err != nil {1219 log.Fatal(err)1320 }@@ -1,11 +1,16 @@
11 package main22 33 import (4+ "encoding/json"45 "fmt"6+ "log"7+ "strconv"58 69 "github.com/FelineStateMachine/puzzletea/game"710 "github.com/FelineStateMachine/puzzletea/hashiwokakero"11+ "github.com/FelineStateMachine/puzzletea/namegen"812 "github.com/FelineStateMachine/puzzletea/nonogram"13+ "github.com/FelineStateMachine/puzzletea/store"914 "github.com/FelineStateMachine/puzzletea/sudoku"1015 "github.com/FelineStateMachine/puzzletea/wordsearch"1116 "github.com/charmbracelet/bubbles/list"@@ -45,14 +50,19 @@ type model struct {
4550 debug bool4651 debugRenderer *glamour.TermRenderer4752 debuginfo string53+54+ store *store.Store55+ activeGameID int6456+ completionSaved bool4857 }4958 50-func initialModel() model {59+func initialModel(s *store.Store) model {5160 r := initDebugRenderer()5261 l := initGameSelectList()5362 return model{54- debugRenderer: r,63+ debugRenderer: r,5564 gameSelectList: l,65+ store: s,5666 }5767 }5868 @@ -75,6 +85,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
7585 case tea.KeyMsg:7686 switch msg.Type {7787 case tea.KeyCtrlN:88+ m = saveCurrentGame(m, store.StatusInProgress)7889 m.state = gameSelectView7990 m.debug = false8091 case tea.KeyEnter:@@ -91,8 +102,34 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
91102 }92103 if m.state == modeSelectView {93104 m.mode, _ = m.modeSelectList.SelectedItem().(game.Mode)94- m.game, _ = m.mode.(game.Spawner).Spawn()105+ g, err := m.mode.(game.Spawner).Spawn()106+ if err != nil {107+ return m, nil108+ }109+ m.game = g95110 m.state = gameView111+ m.completionSaved = false112+113+ // Capture initial state and create DB record.114+ initialState, err := m.game.GetSave()115+ if err != nil {116+ log.Printf("failed to get initial save: %v", err)117+ return m, nil118+ }119+ name := generateUniqueName(m.store)120+ rec := &store.GameRecord{121+ Name: name,122+ GameType: m.selectedCategory.Name,123+ Mode: m.mode.Title(),124+ InitialState: string(initialState),125+ SaveState: string(initialState),126+ Status: store.StatusNew,127+ }128+ if err := m.store.CreateGame(rec); err != nil {129+ log.Printf("failed to create game record: %v", err)130+ } else {131+ m.activeGameID = rec.ID132+ }96133 return m, nil97134 }98135 case tea.KeyEscape:@@ -101,6 +138,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
101138 return m, nil102139 }103140 case tea.KeyCtrlC:141+ m = saveCurrentGame(m, store.StatusAbandoned)104142 return m, tea.Quit105143 case tea.KeyCtrlE:106144 m.debug = !m.debug@@ -113,6 +151,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
113151 if m.debug {114152 m.debuginfo = m.renderDebugInfo()115153 }154+ if !m.completionSaved && isSolvedFromSave(m.game) {155+ m.completionSaved = true156+ saveData, err := m.game.GetSave()157+ if err == nil {158+ _ = m.store.UpdateSaveState(m.activeGameID, string(saveData))159+ }160+ _ = m.store.UpdateStatus(m.activeGameID, store.StatusCompleted)161+ }116162 case gameSelectView:117163 m.gameSelectList, cmd = m.gameSelectList.Update(msg)118164 case modeSelectView:@@ -186,3 +232,65 @@ func (m model) renderDebugInfo() string {
186232 187233 return s188234 }235+236+// saveCurrentGame saves the current game state to the DB if a game is active.237+func saveCurrentGame(m model, status store.GameStatus) model {238+ if m.game == nil || m.activeGameID == 0 {239+ return m240+ }241+ saveData, err := m.game.GetSave()242+ if err != nil {243+ log.Printf("failed to get save data: %v", err)244+ return m245+ }246+ _ = m.store.UpdateSaveState(m.activeGameID, string(saveData))247+ // Don't overwrite a completed status when navigating away.248+ if !(m.completionSaved && status != store.StatusCompleted) {249+ _ = m.store.UpdateStatus(m.activeGameID, status)250+ }251+ m.activeGameID = 0252+ m.game = nil253+ return m254+}255+256+// generateUniqueName generates a unique adjective-noun name, retrying if needed.257+func generateUniqueName(s *store.Store) string {258+ for range 100 {259+ name := namegen.Generate()260+ exists, err := s.NameExists(name)261+ if err != nil || !exists {262+ return name263+ }264+ }265+ // Fallback: append a number266+ for i := 1; ; i++ {267+ name := namegen.Generate() + "-" + strconv.Itoa(i)268+ exists, err := s.NameExists(name)269+ if err != nil || !exists {270+ return name271+ }272+ }273+}274+275+// isSolvedFromSave checks whether the game's save data indicates a solved/won state.276+func isSolvedFromSave(g game.Gamer) bool {277+ data, err := g.GetSave()278+ if err != nil {279+ return false280+ }281+ var fields map[string]json.RawMessage282+ if err := json.Unmarshal(data, &fields); err != nil {283+ return false284+ }285+ for _, key := range []string{"solved", "won"} {286+ raw, ok := fields[key]287+ if !ok {288+ continue289+ }290+ var val bool291+ if json.Unmarshal(raw, &val) == nil && val {292+ return true293+ }294+ }295+ return false296+}@@ -0,0 +1,62 @@
1+package namegen2+3+import "math/rand/v2"4+5+var adjectives = []string{6+ "amber", "ancient", "aqua", "arctic", "autumn",7+ "blazing", "bold", "bright", "bronze", "calm",8+ "cedar", "celestial", "cherry", "chilly", "citrus",9+ "clever", "cobalt", "copper", "coral", "cosmic",10+ "crimson", "crystal", "daring", "dawn", "deep",11+ "desert", "diamond", "drifting", "dusk", "dusty",12+ "eager", "echo", "elegant", "ember", "emerald",13+ "endless", "fading", "fierce", "fiery", "floral",14+ "foggy", "forest", "frosty", "gentle", "gilded",15+ "glacial", "gleaming", "glowing", "golden", "granite",16+ "grassy", "hidden", "hollow", "humble", "hushed",17+ "icy", "indigo", "iron", "ivory", "jade",18+ "keen", "lavender", "lazy", "lemon", "lightning",19+ "lofty", "lonely", "lunar", "marble", "meadow",20+ "mighty", "misty", "molten", "mossy", "muted",21+ "noble", "obsidian", "ocean", "onyx", "opal",22+ "pale", "pearl", "pine", "plum", "polar",23+ "prairie", "quiet", "radiant", "rapid", "raven",24+ "roaming", "ruby", "rustic", "sable", "sage",25+ "sandy", "scarlet", "serene", "shadow", "silver",26+ "sleepy", "solar", "starry", "steel", "stone",27+ "stormy", "sunlit", "swift", "tawny", "tidal",28+ "timber", "topaz", "twilight", "velvet", "violet",29+ "wandering", "whisper", "wild", "winter", "woven",30+}31+32+var nouns = []string{33+ "acorn", "arch", "aurora", "badger", "basalt",34+ "beacon", "birch", "bison", "blaze", "boulder",35+ "bramble", "brook", "canyon", "cedar", "cliff",36+ "cloud", "compass", "condor", "coral", "coyote",37+ "crane", "creek", "crest", "crow", "delta",38+ "dune", "eagle", "elk", "falcon", "fern",39+ "fjord", "flame", "flint", "forge", "fossil",40+ "fox", "frost", "geyser", "glacier", "grove",41+ "harbor", "hawk", "heath", "heron", "hollow",42+ "horizon", "island", "ivy", "jasper", "juniper",43+ "lagoon", "lantern", "larch", "lava", "leaf",44+ "ledge", "lichen", "lynx", "marsh", "mesa",45+ "meteor", "mist", "moon", "moss", "moth",46+ "mountain", "nebula", "newt", "oak", "orchid",47+ "osprey", "otter", "owl", "peak", "pebble",48+ "pine", "plover", "pond", "quail", "quartz",49+ "rain", "raptor", "reef", "ridge", "river",50+ "robin", "sage", "seal", "sequoia", "shell",51+ "shore", "sparrow", "spruce", "star", "stone",52+ "storm", "summit", "thistle", "thorn", "tide",53+ "timber", "trail", "trout", "vale", "valley",54+ "viper", "vista", "willow", "wolf", "wren",55+}56+57+// Generate returns a random "adjective-noun" name.58+func Generate() string {59+ adj := adjectives[rand.IntN(len(adjectives))]60+ noun := nouns[rand.IntN(len(nouns))]61+ return adj + "-" + noun62+}@@ -0,0 +1,25 @@
1+package store2+3+import "time"4+5+type GameStatus string6+7+const (8+ StatusNew GameStatus = "new"9+ StatusInProgress GameStatus = "in_progress"10+ StatusCompleted GameStatus = "completed"11+ StatusAbandoned GameStatus = "abandoned"12+)13+14+type GameRecord struct {15+ ID int6416+ Name string17+ GameType string18+ Mode string19+ InitialState string20+ SaveState string21+ Status GameStatus22+ CreatedAt time.Time23+ UpdatedAt time.Time24+ CompletedAt *time.Time25+}@@ -0,0 +1,121 @@
1+package store2+3+import (4+ "database/sql"5+ "fmt"6+ "os"7+ "path/filepath"8+ "time"9+10+ _ "modernc.org/sqlite"11+)12+13+const createTableSQL = `14+CREATE TABLE IF NOT EXISTS games (15+ id INTEGER PRIMARY KEY AUTOINCREMENT,16+ name TEXT NOT NULL UNIQUE,17+ game_type TEXT NOT NULL,18+ mode TEXT NOT NULL,19+ initial_state TEXT NOT NULL,20+ save_state TEXT,21+ status TEXT NOT NULL DEFAULT 'new'22+ CHECK(status IN ('new','in_progress','completed','abandoned')),23+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,24+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,25+ completed_at DATETIME26+);`27+28+type Store struct {29+ db *sql.DB30+}31+32+// DefaultDBPath returns ~/.puzzletea/history.db.33+func DefaultDBPath() string {34+ home, _ := os.UserHomeDir()35+ return filepath.Join(home, ".puzzletea", "history.db")36+}37+38+// Open creates the directory if needed, opens the database, creates the table,39+// and enables WAL mode.40+func Open(dbPath string) (*Store, error) {41+ dir := filepath.Dir(dbPath)42+ if err := os.MkdirAll(dir, 0o755); err != nil {43+ return nil, fmt.Errorf("creating db directory: %w", err)44+ }45+46+ db, err := sql.Open("sqlite", dbPath)47+ if err != nil {48+ return nil, fmt.Errorf("opening database: %w", err)49+ }50+51+ if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {52+ db.Close()53+ return nil, fmt.Errorf("enabling WAL mode: %w", err)54+ }55+56+ if _, err := db.Exec(createTableSQL); err != nil {57+ db.Close()58+ return nil, fmt.Errorf("creating table: %w", err)59+ }60+61+ return &Store{db: db}, nil62+}63+64+// CreateGame inserts a new game record and sets rec.ID.65+func (s *Store) CreateGame(rec *GameRecord) error {66+ result, err := s.db.Exec(67+ `INSERT INTO games (name, game_type, mode, initial_state, save_state, status)68+ VALUES (?, ?, ?, ?, ?, ?)`,69+ rec.Name, rec.GameType, rec.Mode, rec.InitialState, rec.SaveState, rec.Status,70+ )71+ if err != nil {72+ return fmt.Errorf("inserting game: %w", err)73+ }74+ id, err := result.LastInsertId()75+ if err != nil {76+ return fmt.Errorf("getting last insert id: %w", err)77+ }78+ rec.ID = id79+ return nil80+}81+82+// UpdateSaveState updates save_state and updated_at for the given game.83+func (s *Store) UpdateSaveState(id int64, saveState string) error {84+ _, err := s.db.Exec(85+ `UPDATE games SET save_state = ?, updated_at = ? WHERE id = ?`,86+ saveState, time.Now().UTC(), id,87+ )88+ return err89+}90+91+// UpdateStatus updates the status (and completed_at if completed) for the given game.92+func (s *Store) UpdateStatus(id int64, status GameStatus) error {93+ if status == StatusCompleted {94+ now := time.Now().UTC()95+ _, err := s.db.Exec(96+ `UPDATE games SET status = ?, completed_at = ?, updated_at = ? WHERE id = ?`,97+ status, now, now, id,98+ )99+ return err100+ }101+ _, err := s.db.Exec(102+ `UPDATE games SET status = ?, updated_at = ? WHERE id = ?`,103+ status, time.Now().UTC(), id,104+ )105+ return err106+}107+108+// NameExists checks whether a game with the given name already exists.109+func (s *Store) NameExists(name string) (bool, error) {110+ var count int111+ err := s.db.QueryRow(`SELECT COUNT(*) FROM games WHERE name = ?`, name).Scan(&count)112+ if err != nil {113+ return false, err114+ }115+ return count > 0, nil116+}117+118+// Close closes the database connection.119+func (s *Store) Close() error {120+ return s.db.Close()121+}@@ -1,6 +1,50 @@
11 package sudoku22 3+import (4+ "encoding/json"5+ "fmt"6+ "strings"7+)8+9+type exportCell struct {10+ X int `json:"x"`11+ Y int `json:"y"`12+ V int `json:"v"`13+}14+15+type Save struct {16+ Solved bool `json:"solved"`17+ Grid string `json:"grid"`18+ Provided []exportCell `json:"provided"`19+}20+21+func gridToString(g grid) string {22+ var b strings.Builder23+ for y := range GRIDSIZE {24+ for x := range GRIDSIZE {25+ b.WriteByte(byte('0' + g[y][x].v))26+ }27+ if y < GRIDSIZE-1 {28+ b.WriteByte('\n')29+ }30+ }31+ return b.String()32+}33+334 // GetSave implements game.Gamer.435 func (m Model) GetSave() ([]byte, error) {5- panic("unimplemented")36+ provided := make([]exportCell, len(m.provided))37+ for i, c := range m.provided {38+ provided[i] = exportCell{X: c.x, Y: c.y, V: c.v}39+ }40+ save := Save{41+ Solved: m.isSolved(),42+ Grid: gridToString(m.grid),43+ Provided: provided,44+ }45+ jsonData, err := json.Marshal(save)46+ if err != nil {47+ return nil, fmt.Errorf("unable to marshal save data: %v", err)48+ }49+ return jsonData, nil650 }