clone

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

add continue functionality

Dami committed 7295a1c
commit
7295a1ca8d0c7da863251996aa69654efaf4a778
parent
a470cdf7f6047fea16ec34ed067e28c5cbe6c449
author
Dami <dami@Damis-MacBook-Air.local>
9 files changed, +390 -33
ACLAUDE.md120+++++++++++++++++++++++
Mhashiwokakero/Export.go23++++
Mmodel.go155+++++++++++++++++++++++++++++-
Mnonogram/Export.go15+++
Mstore/store.go34+++++++
Msudoku/Export.go32++++++-
Mwordsearch/Export.go23+----
Mwordsearch/Model.go17+++-
Mwordsearch/keys.go4+-
A CLAUDE.md +120 -0
@@ -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)
M hashiwokakero/Export.go +23 -0
M model.go +148 -7
@@ -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(),
M nonogram/Export.go +15 -0
M store/store.go +34 -0
M sudoku/Export.go +31 -1
M wordsearch/Export.go +3 -20
M wordsearch/Model.go +15 -2
M wordsearch/keys.go +1 -3