- commit
- 7295a1ca8d0c7da863251996aa69654efaf4a778
- parent
- a470cdf7f6047fea16ec34ed067e28c5cbe6c449
- author
- Dami <dami@Damis-MacBook-Air.local>
| A | CLAUDE.md | 120 | +++++++++++++++++++++++ |
| M | hashiwokakero/ | 23 | ++++ |
| M | model.go | 155 | +++++++++++++++++++++++++++++- |
| M | nonogram/ | 15 | +++ |
| M | store/ | 34 | +++++++ |
| M | sudoku/ | 32 | ++++++- |
| M | wordsearch/ | 23 | +---- |
| M | wordsearch/ | 17 | +++- |
| M | wordsearch/ | 4 | +- |
@@ -0,0 +1,120 @@
1+# CLAUDE.md2+3+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.4+5+## Project Overview6+7+PuzzleTea is a terminal-based puzzle game framework built with the Bubble Tea TUI library. It provides a plugin architecture for different puzzle types with a shared menu system and debug interface.8+9+## Commands10+11+### Building and Running12+```bash13+# Run the application14+go run .15+16+# Build the binary17+go build -o puzzletea18+19+# Update dependencies20+go mod tidy21+```22+23+### Testing24+```bash25+# Run all tests (no tests exist yet)26+go test ./...27+28+# Run tests for a specific package29+go test ./nonogram30+```31+32+## Architecture33+34+### Core Application Structure35+36+The application follows a **plugin-based architecture** where puzzle games implement the `game.Gamer` interface:37+38+- **Main entry point** (`main.go`): Initializes the Bubble Tea program39+- **Root model** (`model.go`): Manages application state with three views:40+ - `gameSelectView`: Category selection (Nonogram, Word Search, Sudoku, Hashiwokakero)41+ - `modeSelectView`: Difficulty/mode selection within a category42+ - `gameView`: Active game instance43+44+Navigation flow: select a game category → select a mode/difficulty → play the game. `Escape` returns from mode select to game select, `Ctrl+N` returns to game select from anywhere.45+46+### Plugin Interface (`game/gamer.go`)47+48+All puzzle games must implement:49+50+1. **`game.Gamer`** - The active game instance (Init, Update, View, GetDebugInfo, GetFullHelp, GetSave)51+2. **`game.Spawner`** - Creates a new game instance: `Spawn() (Gamer, error)`52+3. **`game.BaseMode`** - Embed this in your mode struct to get `Title()`, `Description()`, and `FilterValue()` for free53+4. **`game.Category`** - Groups related modes under a heading in the menu; satisfies `Mode` but not `Spawner`54+55+### Adding a New Puzzle Type56+57+1. Create a new package under the project root (e.g., `mypuzzle/`)58+2. Define a mode struct that embeds `game.BaseMode` and implements `game.Spawner`59+3. Use compile-time interface checks: `var _ game.Mode = MyMode{}` and `var _ game.Spawner = MyMode{}`60+4. Export a `Modes` variable (`[]list.Item`) listing available difficulties61+5. Implement `game.Gamer` on a `Model` struct62+6. Register the category in `model.go`'s `GameCategories` slice63+64+Each package follows a consistent file structure:65+- `Gamemode.go`: Mode struct, `NewMode()`, `Spawn()`, `Modes` var66+- `Model.go`: `Model` struct implementing `game.Gamer`67+- `Export.go`: Exported `Save` struct, `GetSave()` method (on Model), `ImportModel([]byte) (*Model, error)` function68+- `keys.go`: `KeyMap` struct with game-specific keybindings, `var DefaultKeyMap = KeyMap{...}`69+- `style.go`: lipgloss styling definitions70+- `generator.go`: Puzzle generation logic (where applicable)71+72+### Current Implementations73+74+**Nonogram** (`nonogram/`): Grid-based puzzle with row/column hints. Three tile states: filled (`.`), marked (`-`), empty (` `). Victory when current grid tomography matches hint tomography.75+76+**Word Search** (`wordsearch/`): Find hidden words in a letter grid. Select start and end positions to highlight words. Supports 8 directions (Right, Down, DownRight, DownLeft, Left, Up, UpRight, UpLeft) with configurable subsets per difficulty.77+78+**Sudoku** (`sudoku/`): Classic 9x9 sudoku with configurable number of provided cells. Uses `cell` type with provided/user-entered distinction.79+80+**Hashiwokakero** (`hashiwokakero/`): Connect islands with bridges (1 or 2). Two input modes: navigation mode (move cursor between islands) and bridge mode (select an island, then cycle bridges in a direction). Victory requires all islands satisfied and connected. Uses `Island`, `Bridge`, `Puzzle` types.81+82+### Global Keybindings83+84+Handled by root model:85+- `Ctrl+N`: Return to game select from any view86+- `Ctrl+C`: Quit application87+- `Ctrl+E`: Toggle debug overlay88+- `Enter`: Select item in menus89+- `Escape`: Return from mode select to game select90+91+Individual games handle their own gameplay keybindings via `KeyMap` types in `keys.go`.92+93+In `model.go`, key events are handled in a single `switch msg.Type` block. Each key type (e.g. `tea.KeyEnter`) has **one `case`** with an if-chain that checks `m.state` to determine per-view behavior. Never add a duplicate `case` for the same key type — add new state checks within the existing case.94+95+### Continue View96+97+The continue view (`continueView`) displays saved games in a `table.Model`. Game records are cached in `m.continueGames` (populated by `initContinueTable()` each time the user navigates to the view). Use this slice directly by index to look up a selected game — no need to re-query the store. Pressing Enter on a row calls the matching package's `ImportModel()` to restore the game.98+99+### Debug System100+101+The debug overlay (`Ctrl+E`) displays game-specific markdown rendered via `glamour`. Each game provides debug info via `GetDebugInfo()` — typically cursor position, dimensions, solved state, and game-specific details.102+103+### Grid System Pattern104+105+Puzzle implementations use a similar grid pattern:106+- Internal grid type: 2D slice (`[][]rune` for nonogram/wordsearch, `[][]cell` for sudoku)107+- `state` type: String serialization for save/load108+- Conversion functions: `newGrid(state)` and `grid.String()`109+110+Hashiwokakero uses a different approach with `Island` and `Bridge` structs rather than a 2D grid.111+112+## Module Information113+114+- **Module path**: `github.com/FelineStateMachine/puzzletea`115+- **Go version**: 1.24.5116+- **Key dependencies**:117+ - `github.com/charmbracelet/bubbletea`: TUI framework118+ - `github.com/charmbracelet/bubbles`: TUI components (list)119+ - `github.com/charmbracelet/lipgloss`: Terminal styling120+ - `github.com/charmbracelet/glamour`: Markdown rendering (debug view)@@ -13,6 +13,29 @@ type Save struct {
1313 Bridges []Bridge `json:"bridges"`1414 }1515 16+func ImportModel(data []byte) (*Model, error) {17+ var save Save18+ if err := json.Unmarshal(data, &save); err != nil {19+ return nil, err20+ }21+22+ cursorIsland := 023+ if len(save.Islands) > 0 {24+ cursorIsland = save.Islands[0].ID25+ }26+27+ return &Model{28+ puzzle: Puzzle{29+ Width: save.Width,30+ Height: save.Height,31+ Islands: save.Islands,32+ Bridges: save.Bridges,33+ },34+ cursorIsland: cursorIsland,35+ keys: DefaultKeyMap,36+ }, nil37+}38+1639 func (m Model) GetSave() ([]byte, error) {1740 save := Save{1841 Solved: m.puzzle.IsSolved(),@@ -14,6 +14,7 @@ import (
1414 "github.com/FelineStateMachine/puzzletea/sudoku"1515 "github.com/FelineStateMachine/puzzletea/wordsearch"1616 "github.com/charmbracelet/bubbles/list"17+ "github.com/charmbracelet/bubbles/table"1718 tea "github.com/charmbracelet/bubbletea"1819 "github.com/charmbracelet/glamour"1920 "github.com/charmbracelet/lipgloss"@@ -32,18 +33,40 @@ var (
3233 )3334 3435 const (35- gameSelectView = iota36+ mainMenuView = iota37+ gameSelectView3638 modeSelectView3739 gameView40+ continueView3841 )3942 43+// menuItem satisfies list.Item for the main menu.44+type menuItem struct {45+ title string46+ desc string47+}48+49+func (i menuItem) Title() string { return i.title }50+func (i menuItem) Description() string { return i.desc }51+func (i menuItem) FilterValue() string { return i.title }52+53+var mainMenuItems = []list.Item{54+ menuItem{title: "Daily Puzzle", desc: "A new puzzle every day."},55+ menuItem{title: "New Puzzle", desc: "Start a new puzzle game."},56+ menuItem{title: "Continue", desc: "Resume a saved game."},57+}58+4059 type model struct {4160 state int4261 62+ mainMenuList list.Model4363 gameSelectList list.Model4464 modeSelectList list.Model4565 selectedCategory game.Category4666 67+ continueTable table.Model68+ continueGames []store.GameRecord69+4770 mode game.Mode4871 game game.Gamer4972 @@ -59,9 +82,12 @@ type model struct {
5982 func initialModel(s *store.Store) model {6083 r := initDebugRenderer()6184 l := initGameSelectList()85+ ml := initMainMenuList()6286 return model{63- debugRenderer: r,87+ state: mainMenuView,88+ debugRenderer: r,6489 gameSelectList: l,90+ mainMenuList: ml,6591 store: s,6692 }6793 }@@ -77,18 +103,37 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
77103 case tea.WindowSizeMsg:78104 h, v := rootStyle.GetFrameSize()79105 w, ht := msg.Width-h, msg.Height-v106+ m.mainMenuList.SetSize(w, ht)80107 m.gameSelectList.SetSize(w, ht)81108 if m.state == modeSelectView {82109 m.modeSelectList.SetSize(w, ht)83110 }111+ if m.state == continueView {112+ m.continueTable.SetWidth(w)113+ m.continueTable.SetHeight(ht)114+ }84115 85116 case tea.KeyMsg:86117 switch msg.Type {87118 case tea.KeyCtrlN:88119 m = saveCurrentGame(m, store.StatusInProgress)89- m.state = gameSelectView120+ m.state = mainMenuView90121 m.debug = false91122 case tea.KeyEnter:123+ if m.state == mainMenuView {124+ item, ok := m.mainMenuList.SelectedItem().(menuItem)125+ if !ok {126+ return m, nil127+ }128+ switch item.title {129+ case "New Puzzle":130+ m.state = gameSelectView131+ case "Continue":132+ m.initContinueTable()133+ m.state = continueView134+ }135+ return m, nil136+ }92137 if m.state == gameSelectView {93138 cat, ok := m.gameSelectList.SelectedItem().(game.Category)94139 if !ok {@@ -132,11 +177,45 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
132177 }133178 return m, nil134179 }180+ if m.state == continueView {181+ idx := m.continueTable.Cursor()182+ if idx < 0 || idx >= len(m.continueGames) {183+ return m, nil184+ }185+ rec := m.continueGames[idx]186+ var g game.Gamer187+ var err error188+ switch rec.GameType {189+ case "Nonogram":190+ g, err = nonogram.ImportModel([]byte(rec.SaveState))191+ case "Word Search":192+ g, err = wordsearch.ImportModel([]byte(rec.SaveState))193+ case "Sudoku":194+ g, err = sudoku.ImportModel([]byte(rec.SaveState))195+ case "Hashiwokakero":196+ g, err = hashiwokakero.ImportModel([]byte(rec.SaveState))197+ default:198+ return m, nil199+ }200+ if err != nil {201+ log.Printf("failed to import game: %v", err)202+ return m, nil203+ }204+ m.game = g205+ m.activeGameID = rec.ID206+ m.state = gameView207+ m.completionSaved = rec.Status == store.StatusCompleted208+ return m, nil209+ }135210 case tea.KeyEscape:136211 if m.state == modeSelectView {137212 m.state = gameSelectView138213 return m, nil139214 }215+ if m.state == gameSelectView || m.state == continueView {216+ m.state = mainMenuView217+ return m, nil218+ }140219 case tea.KeyCtrlC:141220 m = saveCurrentGame(m, store.StatusAbandoned)142221 return m, tea.Quit@@ -146,6 +225,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
146225 }147226 148227 switch m.state {228+ case mainMenuView:229+ m.mainMenuList, cmd = m.mainMenuList.Update(msg)149230 case gameView:150231 m.game, cmd = m.game.Update(msg)151232 if m.debug {@@ -163,6 +244,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
163244 m.gameSelectList, cmd = m.gameSelectList.Update(msg)164245 case modeSelectView:165246 m.modeSelectList, cmd = m.modeSelectList.Update(msg)247+ case continueView:248+ m.continueTable, cmd = m.continueTable.Update(msg)166249 }167250 168251 return m, cmd@@ -172,12 +255,17 @@ func (m model) View() string {
172255 var s string173256 174257 switch m.state {258+ case mainMenuView:259+ return rootStyle.Render(m.mainMenuList.View())175260 case gameSelectView:176- s = rootStyle.Render(m.gameSelectList.View())177- return s261+ return rootStyle.Render(m.gameSelectList.View())178262 case modeSelectView:179- s = rootStyle.Render(m.modeSelectList.View())180- return s263+ return rootStyle.Render(m.modeSelectList.View())264+ case continueView:265+ if len(m.continueGames) == 0 {266+ return rootStyle.Render("No saved games yet.\n\nPress Escape to return.")267+ }268+ return rootStyle.Render(m.continueTable.View())181269 case gameView:182270 var debugInfo string183271 if m.debug {@@ -204,6 +292,10 @@ func initList(items []list.Item, title string) list.Model {
204292 return l205293 }206294 295+func initMainMenuList() list.Model {296+ return initList(mainMenuItems, "PuzzleTea")297+}298+207299 func initGameSelectList() list.Model {208300 return initList(GameCategories, "Select Game")209301 }@@ -212,6 +304,55 @@ func initModeSelectList(cat game.Category) list.Model {
212304 return initList(cat.Modes, cat.Name+" - Select Mode")213305 }214306 307+func (m *model) initContinueTable() {308+ games, err := m.store.ListGames()309+ if err != nil {310+ log.Printf("failed to list games: %v", err)311+ games = nil312+ }313+ m.continueGames = games314+315+ columns := []table.Column{316+ {Title: "Name", Width: 20},317+ {Title: "Game", Width: 15},318+ {Title: "Mode", Width: 15},319+ {Title: "Status", Width: 12},320+ {Title: "Last Updated", Width: 20},321+ }322+323+ rows := make([]table.Row, len(games))324+ for i, g := range games {325+ rows[i] = table.Row{326+ g.Name,327+ g.GameType,328+ g.Mode,329+ formatStatus(g.Status),330+ g.UpdatedAt.Local().Format("Jan 02 15:04"),331+ }332+ }333+334+ t := table.New(335+ table.WithColumns(columns),336+ table.WithRows(rows),337+ table.WithFocused(true),338+ table.WithHeight(10),339+ )340+ m.continueTable = t341+}342+343+func formatStatus(s store.GameStatus) string {344+ switch s {345+ case store.StatusNew:346+ return "New"347+ case store.StatusInProgress:348+ return "In Progress"349+ case store.StatusCompleted:350+ return "Completed"351+ default:352+ return string(s)353+ }354+}355+215356 func initDebugRenderer() *glamour.TermRenderer {216357 renderer, err := glamour.NewTermRenderer(217358 glamour.WithAutoStyle(),@@ -35,6 +35,21 @@ func (m Model) GetSave() ([]byte, error) {
3535 return jsonData, nil3636 }3737 38+func ImportModel(data []byte) (*Model, error) {39+ var save Save40+ if err := json.Unmarshal(data, &save); err != nil {41+ return nil, err42+ }43+ return &Model{44+ width: save.Width,45+ height: save.Height,46+ rowHints: save.RowHints,47+ colHints: save.ColHints,48+ grid: newGrid(state(save.State)),49+ keys: DefaultKeyMap,50+ }, nil51+}52+3853 func (m Model) GetTomography() Hints {3954 return generateTomography(m.grid)4055 }@@ -115,6 +115,40 @@ func (s *Store) NameExists(name string) (bool, error) {
115115 return count > 0, nil116116 }117117 118+// ListGames returns all non-abandoned games ordered by most recently updated.119+func (s *Store) ListGames() ([]GameRecord, error) {120+ rows, err := s.db.Query(121+ `SELECT id, name, game_type, mode, initial_state, save_state, status,122+ created_at, updated_at, completed_at123+ FROM games124+ WHERE status != ?125+ ORDER BY updated_at DESC`,126+ StatusAbandoned,127+ )128+ if err != nil {129+ return nil, fmt.Errorf("querying games: %w", err)130+ }131+ defer rows.Close()132+133+ var games []GameRecord134+ for rows.Next() {135+ var g GameRecord136+ var completedAt sql.NullTime137+ if err := rows.Scan(138+ &g.ID, &g.Name, &g.GameType, &g.Mode,139+ &g.InitialState, &g.SaveState, &g.Status,140+ &g.CreatedAt, &g.UpdatedAt, &completedAt,141+ ); err != nil {142+ return nil, fmt.Errorf("scanning game row: %w", err)143+ }144+ if completedAt.Valid {145+ g.CompletedAt = &completedAt.Time146+ }147+ games = append(games, g)148+ }149+ return games, rows.Err()150+}151+118152 // Close closes the database connection.119153 func (s *Store) Close() error {120154 return s.db.Close()@@ -31,7 +31,37 @@ func gridToString(g grid) string {
3131 return b.String()3232 }3333 34-// GetSave implements game.Gamer.34+func ImportModel(data []byte) (*Model, error) {35+ var save Save36+ if err := json.Unmarshal(data, &save); err != nil {37+ return nil, err38+ }39+40+ provided := make([]cell, len(save.Provided))41+ for i, ec := range save.Provided {42+ provided[i] = cell{x: ec.X, y: ec.Y, v: ec.V}43+ }44+45+ g := newGrid(provided)46+47+ // Parse save.Grid to restore user-entered values.48+ rows := strings.Split(save.Grid, "\n")49+ for y := 0; y < GRIDSIZE && y < len(rows); y++ {50+ for x := 0; x < GRIDSIZE && x < len(rows[y]); x++ {51+ v := int(rows[y][x] - '0')52+ if v != 0 {53+ g[y][x].v = v54+ }55+ }56+ }57+58+ return &Model{59+ grid: g,60+ provided: provided,61+ keys: DefaultKeyMap,62+ }, nil63+}64+3565 func (m Model) GetSave() ([]byte, error) {3666 provided := make([]exportCell, len(m.provided))3767 for i, c := range m.provided {@@ -2,7 +2,7 @@ package wordsearch
22 33 import "encoding/json"44 5-type exportData struct {5+type Save struct {66 Width int `json:"width"`77 Height int `json:"height"`88 Grid string `json:"grid"`@@ -15,25 +15,8 @@ type exportData struct {
1515 Won bool `json:"won"`1616 }1717 18-func exportModel(m Model) ([]byte, error) {19- data := exportData{20- Width: m.width,21- Height: m.height,22- Grid: m.grid.String(),23- Words: m.words,24- CursorX: m.cursor.x,25- CursorY: m.cursor.y,26- Selection: int(m.selection),27- SelectionX: m.selectionStart.x,28- SelectionY: m.selectionStart.y,29- Won: m.won,30- }31-32- return json.Marshal(data)33-}34-3518 func ImportModel(data []byte) (*Model, error) {36- var exported exportData19+ var exported Save3720 if err := json.Unmarshal(data, &exported); err != nil {3821 return nil, err3922 }@@ -46,7 +29,7 @@ func ImportModel(data []byte) (*Model, error) {
4629 cursor: cursor{x: exported.CursorX, y: exported.CursorY},4730 selection: selectionState(exported.Selection),4831 selectionStart: cursor{x: exported.SelectionX, y: exported.SelectionY},49- keys: newKeyMap(),32+ keys: DefaultKeyMap,5033 won: exported.Won,5134 }, nil5235 }@@ -1,6 +1,7 @@
11 package wordsearch22 33 import (4+ "encoding/json"45 "fmt"56 "strings"67 @@ -40,7 +41,7 @@ func New(mode WordSearchMode, g grid, words []Word) *Model {
4041 words: words,4142 cursor: cursor{x: 0, y: 0},4243 selection: noSelection,43- keys: newKeyMap(),44+ keys: DefaultKeyMap,4445 won: false,4546 }4647 }@@ -216,7 +217,19 @@ func (m Model) GetFullHelp() [][]key.Binding {
216217 }217218 218219 func (m Model) GetSave() ([]byte, error) {219- return exportModel(m)220+ data := Save{221+ Width: m.width,222+ Height: m.height,223+ Grid: m.grid.String(),224+ Words: m.words,225+ CursorX: m.cursor.x,226+ CursorY: m.cursor.y,227+ Selection: int(m.selection),228+ SelectionX: m.selectionStart.x,229+ SelectionY: m.selectionStart.y,230+ Won: m.won,231+ }232+ return json.Marshal(data)220233 }221234 222235 func (m Model) countFoundWords() int {@@ -13,8 +13,7 @@ type KeyMap struct {
1313 Help key.Binding1414 }1515 16-func newKeyMap() KeyMap {17- return KeyMap{16+var DefaultKeyMap = KeyMap{1817 Up: key.NewBinding(1918 key.WithKeys("up", "k", "w"),2019 key.WithHelp("↑/k/w", "move up"),@@ -47,7 +46,6 @@ func newKeyMap() KeyMap {
4746 key.WithKeys("?"),4847 key.WithHelp("?", "toggle help"),4948 ),50- }5149 }5250 5351 func (k KeyMap) ShortHelp() []key.Binding {