diff --git a/AGENTS.md b/AGENTS.md index fd67188..758148b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,3 +4,9 @@ - Prefer `just test-short` for test runs. - Use the -short flag when testing non-generator code. - Use full-length tests only when validating long-running generator behavior. +- Package map: +- `catalog` is the pure metadata index. +- `registry` is the concrete built-in runtime registry. +- `gameentry` builds validated runtime entries from definitions plus modes. +- `pdfexport` owns the printable export pipeline. +- `builtinprint` only bootstraps built-in print adapters. diff --git a/README.md b/README.md index 4547cb7..0b22c62 100644 --- a/README.md +++ b/README.md @@ -2,19 +2,19 @@ A terminal-based puzzle game collection built with [Bubble Tea](https://github.com/charmbracelet/bubbletea). -Twelve puzzle types, multiple difficulty modes, daily and weekly deterministic challenges, XP progression, 365 color themes, and an explicit game catalog for adding new games. +Fourteen puzzle games, multiple difficulty modes, daily and weekly deterministic challenges, XP progression, 365 theme options, and an explicit built-in registry plus metadata catalog for adding new games. ![PuzzleTea menu](vhs/menu.gif) ## Features -- **13 puzzle games** -- Fillomino, Nonogram, Nurikabe, Ripple Effect, Sudoku, Shikaku, Spell Puzzle, Word Search, Hashiwokakero, Hitori, Lights Out, Takuzu, Takuzu+ +- **14 puzzle games** -- Fillomino, Nonogram, Nurikabe, Ripple Effect, Shikaku, Sudoku, Sudoku RGB, Spell Puzzle, Word Search, Hashiwokakero, Hitori, Lights Out, Takuzu, Takuzu+ - **Daily puzzles** -- A unique puzzle generated each day using deterministic seeding. Same date, same puzzle for everyone. Streak tracking rewards consecutive daily completions. - **Weekly gauntlet** -- Each ISO calendar week has a shared 99-puzzle ladder. The current week unlocks sequentially from `#01` to `#99`; past weeks can be reviewed from completed saves only. - **XP and leveling** -- Per-category levels based on victories. Harder modes yield more XP. Daily puzzles grant 2x XP, and weekly puzzles add slot-based bonus XP. - **Stats dashboard** -- Profile level, daily streak status, weekly completion progress, victory counts, and XP progress bars per category. - **365 color themes** -- Live-preview theme picker with WCAG-compliant contrast enforcement. Dark and light themes included. -- **Mouse support** -- Click and drag in Nonogram, Nurikabe, Shikaku, Spell Puzzle, and Word Search. Lights Out supports click-to-toggle. +- **Mouse support** -- Drag interactions in Nonogram, Nurikabe, Shikaku, Spell Puzzle, and Word Search; click-to-focus in Fillomino, Hashiwokakero, Hitori, Sudoku, Sudoku RGB, Takuzu, and Takuzu+; click-to-toggle in Lights Out. - **Seeded puzzles** -- Share a seed string to generate identical puzzles across sessions and machines. - **Save/load persistence** -- Games auto-save to SQLite. Resume any in-progress game by name. @@ -23,13 +23,14 @@ Twelve puzzle types, multiple difficulty modes, daily and weekly deterministic c | Game | Description | Modes | |------|-------------|-------| | **Fillomino** | Grow numbered regions to their exact sizes | Mini 5x5 through Expert 12x12 | -| **Nonogram** | Fill cells to match row and column hints | Easy/Medium/Hard across 5x5, 10x10, 15x15, 20x20 | +| **Nonogram** | Fill cells to match row and column hints | 10 named modes from 5x5 `Mini` to 20x20 `Massive` | | **Nurikabe** | Build islands while keeping one connected sea | 5 modes from 5x5 to 12x12 | | **Ripple Effect** | Place digits in cages without violating ripple distance | Mini 5x5 through Expert 9x9 | -| **Shikaku** | Divide grid into rectangles matching cell counts | 5 modes from 7x7 to 11x11 | +| **Shikaku** | Divide grid into rectangles matching cell counts | Mini 5x5 through Expert 12x12 | | **Sudoku** | Classic 9x9 grid | Beginner, Easy, Medium, Hard, Expert, Diabolical | +| **Sudoku RGB** | Fill a 9x9 grid with three symbols so each row, column, and box contains three of each | Beginner, Easy, Medium, Hard, Expert, Diabolical | | **Spell Puzzle** | Connect letters to reveal crossword words and bonus anagrams | Beginner, Easy, Medium, Hard | -| **Word Search** | Find hidden words in a letter grid | Easy, Medium, Hard (3-8 directions) | +| **Word Search** | Find hidden words in a letter grid | Easy 10x10, Medium 15x15, Hard 20x20 | | **Hashiwokakero** | Connect islands with bridges | 12 modes across 7x7 to 13x13 grids | | **Hitori** | Shade cells to eliminate duplicates | 6 modes from 5x5 to 12x12 | | **Lights Out** | Toggle lights to turn all off | Easy (3x3) to Extreme (9x9) | @@ -99,15 +100,19 @@ that week. Current-week puzzles unlock one at a time; older weeks are review-onl Start a new game directly: ```bash -puzzletea new nonogram medium +puzzletea new nonogram classic puzzletea new fillomino "Hard 10x10" puzzletea new ripple-effect "Medium 7x7" puzzletea new sudoku hard +puzzletea new ripeto expert puzzletea new spell beginner puzzletea new lights-out -puzzletea new hashi easy +puzzletea new hashi "Easy 7x7" ``` +Mode names are matched case-insensitively after normalizing spaces, hyphens, +and underscores. Multi-word mode titles usually need quotes. + Resume and manage saved games: ```bash @@ -167,13 +172,13 @@ puzzletea --theme "Catppuccin Mocha" Flag aliases on the root command also work: ```bash -puzzletea --new nonogram:medium +puzzletea --new nonogram:classic puzzletea --continue amber-falcon ``` ### CLI Aliases -Several shorthand names are accepted for games: `polyomino`/`regions` for Fillomino, `hashi`/`bridges` for Hashiwokakero, `lights` for Lights Out, `islands`/`sea` for Nurikabe, `ripple` for Ripple Effect, `spell`/`spellpuzzle` for Spell Puzzle, `binairo`/`binary` for Takuzu, `binario+` for Takuzu+, `words`/`ws` for Word Search, `rectangles` for Shikaku. +Several shorthand names are accepted for games: `polyomino`/`regions` for Fillomino, `hashi`/`bridges` for Hashiwokakero, `lights` for Lights Out, `islands`/`sea` for Nurikabe, `ripple` for Ripple Effect, `spell`/`spellpuzzle` for Spell Puzzle, `rgb sudoku`/`ripeto`/`sudoku ripeto` for Sudoku RGB, `binairo`/`binary` for Takuzu, `takuzu plus`/`binario+`/`binario plus` for Takuzu+, `words`/`wordsearch`/`ws` for Word Search, and `rectangles` for Shikaku. ## Controls @@ -185,7 +190,6 @@ Several shorthand names are accepted for games: `polyomino`/`regions` for Fillom | `Escape` | Return to the menu or go back | | `Ctrl+R` | Reset puzzle | | `Ctrl+H` | Toggle full help | -| `Ctrl+E` | Toggle debug overlay | | `Ctrl+C` | Quit | ### Navigation @@ -194,7 +198,7 @@ Arrow keys, WASD, and Vim bindings (`hjkl`) are supported for grid movement acro ### Mouse -Nonogram, Nurikabe, Shikaku, Spell Puzzle, and Word Search support click and drag. Lights Out supports click to toggle. See each game's help for details. +Nonogram, Nurikabe, Shikaku, Spell Puzzle, and Word Search support drag interactions. Fillomino, Hashiwokakero, Hitori, Sudoku, Sudoku RGB, Takuzu, and Takuzu+ support mouse focus or click-to-cycle interactions. Lights Out supports click to toggle. See each game's help for details. ## Game Persistence @@ -321,7 +325,7 @@ All code must pass `gofumpt` and `golangci-lint` before committing. CI runs both ## Adding a New Puzzle -PuzzleTea uses an explicit game catalog. To add a new puzzle type: +PuzzleTea uses an explicit built-in registry plus a metadata catalog. To add a new puzzle type: ### 1. Create the game package @@ -329,21 +333,26 @@ Create a directory (e.g., `mypuzzle/`) with these files: | File | Purpose | |------|---------| -| `Gamemode.go` | Mode struct embedding `game.BaseMode`, `Spawn()`, `Modes`/`DailyModes`, and package-level `Definition` metadata | -| `Model.go` | `Model` struct implementing `game.Gamer` | -| `Export.go` | `Save` struct, `GetSave()`, `ImportModel()` for persistence | +| `gamemode.go` | Mode structs embedding `game.BaseMode`, `Modes`, `ModeDefinitions`, package-level `Definition`, and the built-in `Entry` | +| `model.go` | `Model` struct implementing `game.Gamer` | +| `export.go` | `Save` struct, `GetSave()`, `ImportModel()` for persistence | | `keys.go` | Game-specific `KeyMap` struct | | `style.go` | lipgloss styling and rendering helpers | | `generator.go` | Puzzle generation logic (if applicable) | | `grid.go` | Grid type and serialization (for grid-based games) | +| `help.md` | Embedded rules/help content wired into the runtime entry | +| `print_adapter.go` | Optional printable export adapter for JSONL/PDF support | | `mypuzzle_test.go` | Tests (table-driven, save/load round-trip, generator validation) | | `README.md` | Game docs: rules, controls table, modes table, quick start examples | -### 2. Wire it into the central catalog +Use `gameentry.BuildModeDefs(Modes)` and `gameentry.NewEntry(...)` so the +package exposes both metadata and its validated runtime wiring. + +### 2. Wire it into the built-in registry -Edit the central catalog once: +Edit the built-in registry once: -- **`catalog/catalog.go`**: Import the package and add its exported `Definition` to `All` (maintain alphabetical order). +- **`registry/registry.go`**: Import the package and add its exported `Entry` to `all` (maintain alphabetical order). The game package's `Definition` owns: @@ -355,6 +364,9 @@ The game package's `Definition` owns: - help content - save/import function +The `catalog` package is built from the registered entries and remains the pure +metadata index for names, aliases, and daily-mode lookup. + ### 3. Add a VHS preview - Create `vhs/.tape` following the format in existing tapes. @@ -363,7 +375,9 @@ The game package's `Definition` owns: ### 4. Verify ```bash -just fmt && just lint && just test +just fmt +just lint +just test-short ``` See any existing game package (e.g., `nonogram/`) for the full pattern, and `AGENTS.md` for detailed conventions. diff --git a/app/game_session.go b/app/game_session.go index 958a702..1dcea2a 100644 --- a/app/game_session.go +++ b/app/game_session.go @@ -1,14 +1,8 @@ package app import ( - "log" - - "github.com/FelineStateMachine/puzzletea/game" - sessionflow "github.com/FelineStateMachine/puzzletea/session" "github.com/FelineStateMachine/puzzletea/store" "github.com/FelineStateMachine/puzzletea/weekly" - - tea "charm.land/bubbletea/v2" ) type gameOpenOptions struct { @@ -17,37 +11,11 @@ type gameOpenOptions struct { weeklyInfo *weekly.Info } -// activateGame prepares the game with global UI state and moves to game view. -func (m model) activateGame(g game.Gamer, activeGameID int64, completionSaved bool, options gameOpenOptions) model { - g, _ = g.Update(game.HelpToggleMsg{Show: m.help.showFull}) - g, _ = g.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) - - m.session.game = g - m.session.activeGameID = activeGameID - m.session.completionSaved = completionSaved - m.session.returnState = options.returnState - m.session.weeklyAdvance = options.weeklyInfo - m.state = gameView - return m -} - func (m model) importAndActivateRecord(rec store.GameRecord) (model, bool) { return m.importAndActivateRecordWithOptions(rec, gameOpenOptions{returnState: mainMenuView}) } func (m model) importAndActivateRecordWithOptions(rec store.GameRecord, options gameOpenOptions) (model, bool) { - g, err := sessionflow.ImportRecord(&rec) - if err != nil { - log.Printf("failed to import game: %v", err) - return m, false - } - - activeGameID := rec.ID - completionSaved := rec.Status == store.StatusCompleted - if options.readOnly { - activeGameID = 0 - completionSaved = true - } - - return m.activateGame(g, activeGameID, completionSaved, options), true + ok := newSessionController(&m).importRecord(rec, options) + return m, ok } diff --git a/app/handle_daily.go b/app/handle_daily.go new file mode 100644 index 0000000..d35f306 --- /dev/null +++ b/app/handle_daily.go @@ -0,0 +1,48 @@ +package app + +import ( + "time" + + "github.com/FelineStateMachine/puzzletea/daily" + sessionflow "github.com/FelineStateMachine/puzzletea/session" + "github.com/FelineStateMachine/puzzletea/store" + + tea "charm.land/bubbletea/v2" +) + +func (m model) handleDailyPuzzle() (tea.Model, tea.Cmd) { + today := time.Now() + name := daily.Name(today) + + rec, err := m.store.GetDailyGame(name) + if err != nil { + return m.setErrorf("Could not check today’s daily puzzle: %v", err), nil + } + if rec != nil { + var resumed bool + m, resumed = m.importAndActivateRecord(*rec) + if resumed { + if err := sessionflow.ResumeAbandonedDeterministicRecord(m.store, rec); err != nil { + m = m.setErrorf("%v", err) + } + } + return m, nil + } + + spawner, gameType, modeTitle := daily.Mode(today) + if spawner == nil { + return m.setErrorf("No daily puzzle is configured for %s", today.Format("2006-01-02")), nil + } + + rng := daily.RNG(today) + cmd := newSessionController(&m).startSeededSpawn(spawner, rng, spawnRequest{ + source: spawnSourceDaily, + name: name, + gameType: gameType, + modeTitle: modeTitle, + run: store.DailyRunMetadata(today), + returnState: playMenuView, + exitState: mainMenuView, + }) + return m, cmd +} diff --git a/app/handle_game.go b/app/handle_game.go new file mode 100644 index 0000000..423d196 --- /dev/null +++ b/app/handle_game.go @@ -0,0 +1,56 @@ +package app + +import ( + "github.com/FelineStateMachine/puzzletea/registry" + sessionflow "github.com/FelineStateMachine/puzzletea/session" + "github.com/FelineStateMachine/puzzletea/store" + "github.com/FelineStateMachine/puzzletea/ui" + + tea "charm.land/bubbletea/v2" +) + +func (m model) handleGameSelectEnter() (tea.Model, tea.Cmd) { + entry, ok := selectedCategoryEntry(m.nav.gameSelectList.SelectedItem()) + if !ok { + return m, nil + } + m.nav.selectedCategory = entry + m.nav.modeSelectList = ui.InitList(buildModeDisplayItems(entry), entry.Definition.Name+" - Select Mode") + m.nav.modeSelectList.SetSize(min(m.width, 64), min(m.height, ui.ListHeight(m.nav.modeSelectList))) + m.state = modeSelectView + return m, nil +} + +func (m model) handleModeSelectEnter() (tea.Model, tea.Cmd) { + item := unwrapModeDisplayItem(m.nav.modeSelectList.SelectedItem()) + mode, ok := item.(registry.ModeEntry) + if !ok { + return m, nil + } + m.nav.selectedModeTitle = mode.Definition.Title + cmd := newSessionController(&m).startSpawn(mode.Spawner, spawnRequest{ + source: spawnSourceNormal, + name: sessionflow.GenerateUniqueName(m.store), + gameType: m.nav.selectedCategory.Definition.Name, + modeTitle: m.nav.selectedModeTitle, + run: store.NormalRunMetadata(), + returnState: modeSelectView, + exitState: mainMenuView, + }) + return m, cmd +} + +func (m model) handleContinueEnter() (tea.Model, tea.Cmd) { + idx := m.cont.table.Cursor() + if idx < 0 || idx >= len(m.cont.games) { + return m, nil + } + rec := m.cont.games[idx] + m, _ = m.importAndActivateRecord(rec) + return m, nil +} + +func (m model) persistCompletionIfSolved() model { + newSessionController(&m).persistCompletionIfSolved() + return m +} diff --git a/app/handle_help.go b/app/handle_help.go new file mode 100644 index 0000000..1a9b0f1 --- /dev/null +++ b/app/handle_help.go @@ -0,0 +1,16 @@ +package app + +import ( + tea "charm.land/bubbletea/v2" +) + +func (m model) handleHelpSelectEnter() (tea.Model, tea.Cmd) { + entry, ok := selectedCategoryEntry(m.help.selectList.SelectedItem()) + if !ok { + return m, nil + } + m.help.category = entry + m = m.updateHelpDetailViewport() + m.state = helpDetailView + return m, nil +} diff --git a/app/handle_menu.go b/app/handle_menu.go new file mode 100644 index 0000000..0dacb46 --- /dev/null +++ b/app/handle_menu.go @@ -0,0 +1,41 @@ +package app + +import ( + "time" + + "github.com/FelineStateMachine/puzzletea/ui" + + tea "charm.land/bubbletea/v2" +) + +func (m model) currentWeeklyMenuIndex() int { + if m.store == nil { + return 1 + } + + year, week := time.Now().ISOWeek() + highestCompleted, err := m.store.GetCurrentWeeklyHighestCompletedIndex(year, week) + if err != nil { + return 1 + } + if highestCompleted >= weeklyEntryCount { + return weeklyEntryCount + } + if highestCompleted < 1 { + return 1 + } + return highestCompleted + 1 +} + +func updateMainMenuCursor(msg tea.Msg, menu *ui.MainMenu) { + keyMsg, ok := msg.(tea.KeyPressMsg) + if !ok { + return + } + switch keyMsg.String() { + case "up", "k": + menu.CursorUp() + case "down", "j": + menu.CursorDown() + } +} diff --git a/app/handle_seed.go b/app/handle_seed.go new file mode 100644 index 0000000..20de040 --- /dev/null +++ b/app/handle_seed.go @@ -0,0 +1,66 @@ +package app + +import ( + "github.com/FelineStateMachine/puzzletea/game" + "github.com/FelineStateMachine/puzzletea/registry" + "github.com/FelineStateMachine/puzzletea/resolve" + sessionflow "github.com/FelineStateMachine/puzzletea/session" + "github.com/FelineStateMachine/puzzletea/store" + + tea "charm.land/bubbletea/v2" +) + +func (m model) handleSeedConfirm() (tea.Model, tea.Cmd) { + seed := sessionflow.NormalizeSeed(m.seed.input.Value()) + if seed == "" { + return m, nil + } + + selectedMode := m.currentSeedMode() + name := sessionflow.SeededName(seed) + if selectedMode.key != "" { + name = sessionflow.SeededNameForGame(seed, selectedMode.gameType) + } + + rec, err := m.store.GetDailyGame(name) + if err != nil { + return m.setErrorf("Could not check saved seeded puzzle: %v", err), nil + } + if rec != nil { + var resumed bool + m, resumed = m.importAndActivateRecord(*rec) + if resumed { + if err := sessionflow.ResumeAbandonedDeterministicRecord(m.store, rec); err != nil { + m = m.setErrorf("%v", err) + } + } + return m, nil + } + + var spawner game.SeededSpawner + var gameType string + modeTitle := "" + if selectedMode.key == "" { + spawner, gameType, modeTitle, err = resolve.SeededMode(seed, registry.Entries()) + if err != nil { + return m.setErrorf("Could not choose a seeded mode: %v", err), nil + } + } else { + spawner, gameType, modeTitle, err = resolve.SeededModeForGame(seed, selectedMode.gameType, registry.Entries()) + if err != nil { + return m.setErrorf("Could not choose a seeded mode for %s: %v", selectedMode.gameType, err), nil + } + } + + rng := resolve.RNGFromString(seed) + cmd := newSessionController(&m).startSeededSpawn(spawner, rng, spawnRequest{ + source: spawnSourceSeed, + name: name, + gameType: gameType, + modeTitle: modeTitle, + run: store.SeededRunMetadata(seed), + returnState: playMenuView, + exitState: mainMenuView, + }) + return m, cmd +} diff --git a/app/handle_stats.go b/app/handle_stats.go new file mode 100644 index 0000000..7b84c83 --- /dev/null +++ b/app/handle_stats.go @@ -0,0 +1,68 @@ +package app + +import ( + "time" + + "github.com/FelineStateMachine/puzzletea/daily" + "github.com/FelineStateMachine/puzzletea/registry" + "github.com/FelineStateMachine/puzzletea/stats" + "github.com/FelineStateMachine/puzzletea/ui" + + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" +) + +func (m model) handleStatsEnter() (tea.Model, tea.Cmd) { + m = m.clearNotice() + + catStats, err := m.store.GetCategoryStats() + if err != nil { + return m.setErrorf("Could not load category stats: %v", err), nil + } + modeStats, err := m.store.GetModeStats() + if err != nil { + return m.setErrorf("Could not load mode stats: %v", err), nil + } + streakDates, err := m.store.GetDailyStreakDates() + if err != nil { + return m.setErrorf("Could not load streak data: %v", err), nil + } + weekliesCompleted, err := m.store.GetCompletedWeeklyGauntlets() + if err != nil { + return m.setErrorf("Could not load weekly progress: %v", err), nil + } + now := time.Now() + currentYear, currentWeek := now.ISOWeek() + thisWeekHighestIndex, err := m.store.GetCurrentWeeklyHighestCompletedIndex(currentYear, currentWeek) + if err != nil { + return m.setErrorf("Could not load this week’s progress: %v", err), nil + } + currentDaily := false + rec, err := m.store.GetDailyGame(daily.Name(now)) + if err != nil { + m = m.setErrorf("Could not check today’s daily puzzle: %v", err) + } else { + currentDaily = rec != nil + } + + weights := stats.WeightsFromDefinitions(registry.Definitions()) + m.stats.cards = stats.BuildCards(weights, catStats, modeStats) + m.stats.profile = stats.BuildProfileBanner( + catStats, + modeStats, + weights, + streakDates, + currentDaily, + weekliesCompleted, + thisWeekHighestIndex, + ) + + statsWidth, statsHeight := statsViewportSize(m.width, m.height, m.stats.cards) + m.stats.viewport = viewport.New( + viewport.WithWidth(statsWidth), + viewport.WithHeight(statsHeight), + ) + m.stats.viewport.SetContent(ui.RenderStatsCardGrid(m.stats.cards, statsWidth)) + m.state = statsView + return m, nil +} diff --git a/app/handle_theme.go b/app/handle_theme.go new file mode 100644 index 0000000..7df3403 --- /dev/null +++ b/app/handle_theme.go @@ -0,0 +1,66 @@ +package app + +import ( + "github.com/FelineStateMachine/puzzletea/config" + "github.com/FelineStateMachine/puzzletea/theme" + "github.com/FelineStateMachine/puzzletea/ui" + + "charm.land/bubbles/v2/list" + tea "charm.land/bubbletea/v2" +) + +func (m model) handleThemeEnter() (tea.Model, tea.Cmd) { + m.theme.previous = m.cfg.Theme + + names := theme.ThemeNames() + items := make([]list.Item, len(names)) + for i, n := range names { + desc := "dark theme" + if n == theme.DefaultThemeName { + desc = "built-in earth-tone palette" + } else if t := theme.LookupTheme(n); t != nil && !t.Meta.IsDark { + desc = "light theme" + } + items[i] = ui.MenuItem{ItemTitle: n, Desc: desc} + } + + const maxVisibleItems = 8 + listH := min(m.height, maxVisibleItems*3) + listW := min(m.width, theme.MaxNameLen+4) + + m.theme.list = ui.InitThemeList(items, listW, listH) + for i, item := range items { + if mi, ok := item.(ui.MenuItem); ok && mi.ItemTitle == m.theme.previous { + m.theme.list.Select(i) + break + } + } + if m.theme.previous == "" { + m.theme.list.Select(0) + } + + m.state = themeSelectView + m = m.clearNotice() + return m, nil +} + +func (m model) handleThemeConfirm() (tea.Model, tea.Cmd) { + item, ok := m.theme.list.SelectedItem().(ui.MenuItem) + if !ok { + return m, nil + } + + themeName := item.ItemTitle + if themeName == theme.DefaultThemeName { + themeName = "" + } + + _ = theme.Apply(item.ItemTitle) + m.cfg.Theme = themeName + if err := m.cfg.Save(config.DefaultPath()); err != nil { + m = m.setErrorf("Theme changed, but saving config failed: %v", err) + } + + m.state = mainMenuView + return m, nil +} diff --git a/app/model.go b/app/model.go index 0fa7811..ee6de54 100644 --- a/app/model.go +++ b/app/model.go @@ -33,16 +33,32 @@ var ( type viewState int +const ( + mainMenuActionPlay = "play" + mainMenuActionStats = "stats" + mainMenuActionOptions = "options" + mainMenuActionQuit = "quit" + + playMenuActionCreate = "create" + playMenuActionContinue = "continue" + playMenuActionDaily = "daily" + playMenuActionWeekly = "weekly" + playMenuActionSeeded = "seeded" + + optionsMenuActionTheme = "theme" + optionsMenuActionGuides = "guides" +) + var mainMenuItems = []ui.MenuItem{ - {ItemTitle: "Play", Desc: "start or continue a puzzle"}, - {ItemTitle: "Stats", Desc: "your progress"}, - {ItemTitle: "Options", Desc: "configure and learn"}, - {ItemTitle: "Quit", Desc: "exit puzzletea"}, + {Action: mainMenuActionPlay, ItemTitle: "Play", Desc: "start or continue a puzzle"}, + {Action: mainMenuActionStats, ItemTitle: "Stats", Desc: "your progress"}, + {Action: mainMenuActionOptions, ItemTitle: "Options", Desc: "configure and learn"}, + {Action: mainMenuActionQuit, ItemTitle: "Quit", Desc: "exit puzzletea"}, } var optionsMenuItems = []ui.MenuItem{ - {ItemTitle: "Theme", Desc: "change colors"}, - {ItemTitle: "Guides", Desc: "learn the rules"}, + {Action: optionsMenuActionTheme, ItemTitle: "Theme", Desc: "change colors"}, + {Action: optionsMenuActionGuides, ItemTitle: "Guides", Desc: "learn the rules"}, } const ( @@ -71,17 +87,25 @@ type navigationState struct { modeSelectList list.Model selectedCategory registry.Entry selectedModeTitle string - continueTable table.Model - continueGames []store.GameRecord - weeklyTable table.Model - weeklyRows []weeklyRow - weeklyCursor time.Time - seedInput textinput.Model - seedModeOptions []seedModeOption - seedModeIndex int - seedFocus seedInputFocus - lastSeedModeKey string - helpSelectList list.Model +} + +type continueState struct { + table table.Model + games []store.GameRecord +} + +type weeklyState struct { + table table.Model + rows []weeklyRow + cursor time.Time +} + +type seedState struct { + input textinput.Model + modeOptions []seedModeOption + modeIndex int + focus seedInputFocus + lastModeKey string } type sessionState struct { @@ -97,6 +121,7 @@ type sessionState struct { } type helpState struct { + selectList list.Model category registry.Entry viewport viewport.Model renderer *glamour.TermRenderer @@ -136,6 +161,7 @@ type spawnRequest struct { name string gameType string modeTitle string + run store.RunMetadata returnState viewState exitState viewState weeklyInfo *weekly.Info @@ -158,11 +184,15 @@ type model struct { state viewState nav navigationState + cont continueState session sessionState + seed seedState + weekly weeklyState help helpState stats statsState theme themeState debug debugState + notice noticeState spinner spinner.Model @@ -227,10 +257,10 @@ func (m model) Init() tea.Cmd { func buildPlayMenuItems(now time.Time, currentWeeklyIndex int) []ui.MenuItem { year, week := now.ISOWeek() return []ui.MenuItem{ - {ItemTitle: "Create", Desc: "a new puzzle"}, - {ItemTitle: "Continue", Desc: "a previously played puzzle"}, - {ItemTitle: "Daily", Desc: now.Format("Jan _2 06")}, - {ItemTitle: "Weekly", Desc: "Week " + formatTwoDigits(week) + "-" + strconv.Itoa(year) + " #" + formatWeeklyMenuIndex(currentWeeklyIndex)}, - {ItemTitle: "Seeded", Desc: "enter a specific seed"}, + {Action: playMenuActionCreate, ItemTitle: "Create", Desc: "a new puzzle"}, + {Action: playMenuActionContinue, ItemTitle: "Continue", Desc: "a previously played puzzle"}, + {Action: playMenuActionDaily, ItemTitle: "Daily", Desc: now.Format("Jan _2 06")}, + {Action: playMenuActionWeekly, ItemTitle: "Weekly", Desc: "Week " + formatTwoDigits(week) + "-" + strconv.Itoa(year) + " #" + formatWeeklyMenuIndex(currentWeeklyIndex)}, + {Action: playMenuActionSeeded, ItemTitle: "Seeded", Desc: "enter a specific seed"}, } } diff --git a/app/notice.go b/app/notice.go new file mode 100644 index 0000000..243435f --- /dev/null +++ b/app/notice.go @@ -0,0 +1,66 @@ +package app + +import ( + "fmt" + "log" + + "github.com/FelineStateMachine/puzzletea/ui" + + "charm.land/lipgloss/v2" +) + +type noticeLevel string + +const noticeLevelError noticeLevel = "error" + +type noticeState struct { + level noticeLevel + message string +} + +func (m model) clearNotice() model { + m.notice = noticeState{} + return m +} + +func (m model) setErrorf(format string, args ...any) model { + message := fmt.Sprintf(format, args...) + log.Printf("%s", message) + m.notice = noticeState{ + level: noticeLevelError, + message: message, + } + return m +} + +func (m model) appendNotice(content string) string { + return appendNoticeContent(m.width, m.notice, content) +} + +func appendNoticeContent(width int, notice noticeState, content string) string { + if notice.message == "" { + return content + } + + style := ui.ErrorNoticeStyle() + block := style. + MaxWidth(max(width-8, 24)). + Render(notice.message) + if content == "" { + return block + } + return lipgloss.JoinVertical(lipgloss.Left, content, "", block) +} + +func (m model) renderPanel(title, content, footer string) string { + return renderPanelView(m.width, m.height, m.notice, title, content, footer) +} + +func centerContentWithNotice(width, height int, notice noticeState, content string) string { + return ui.CenterView(width, height, appendNoticeContent(width, notice, content)) +} + +func renderPanelView(width, height int, notice noticeState, title, content, footer string) string { + panel := ui.Panel(title, appendNoticeContent(width, notice, content), footer) + return ui.CenterView(width, height, panel) +} diff --git a/app/screen_core.go b/app/screen_core.go new file mode 100644 index 0000000..b6d1eb1 --- /dev/null +++ b/app/screen_core.go @@ -0,0 +1,190 @@ +package app + +import tea "charm.land/bubbletea/v2" + +type screenAction interface{ isScreenAction() } + +type openPlayMenuAction struct{} + +func (openPlayMenuAction) isScreenAction() {} + +type openStatsAction struct{} + +func (openStatsAction) isScreenAction() {} + +type openOptionsMenuAction struct{} + +func (openOptionsMenuAction) isScreenAction() {} + +type quitAction struct{} + +func (quitAction) isScreenAction() {} + +type openGameSelectAction struct{} + +func (openGameSelectAction) isScreenAction() {} + +type openContinueAction struct{} + +func (openContinueAction) isScreenAction() {} + +type openDailyAction struct{} + +func (openDailyAction) isScreenAction() {} + +type openWeeklyAction struct{} + +func (openWeeklyAction) isScreenAction() {} + +type openSeedInputAction struct{} + +func (openSeedInputAction) isScreenAction() {} + +type backAction struct { + target viewState +} + +func (backAction) isScreenAction() {} + +type gameSelectEnterAction struct{} + +func (gameSelectEnterAction) isScreenAction() {} + +type modeSelectEnterAction struct{} + +func (modeSelectEnterAction) isScreenAction() {} + +type continueEnterAction struct{} + +func (continueEnterAction) isScreenAction() {} + +type weeklyShiftAction struct { + delta int +} + +func (weeklyShiftAction) isScreenAction() {} + +type weeklyEnterAction struct{} + +func (weeklyEnterAction) isScreenAction() {} + +type helpSelectEnterAction struct{} + +func (helpSelectEnterAction) isScreenAction() {} + +type openThemeSelectAction struct{} + +func (openThemeSelectAction) isScreenAction() {} + +type openHelpSelectAction struct{} + +func (openHelpSelectAction) isScreenAction() {} + +type previewThemeAction struct { + name string +} + +func (previewThemeAction) isScreenAction() {} + +type confirmThemeAction struct{} + +func (confirmThemeAction) isScreenAction() {} + +type seedConfirmAction struct{} + +func (seedConfirmAction) isScreenAction() {} + +type screenModel interface { + State() viewState + Resize(width, height int) screenModel + Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) + View(notice noticeState) string + Apply(m model) model +} + +func (m model) activeScreen() screenModel { + switch m.state { + case mainMenuView: + return mainMenuScreen{ + width: m.width, + height: m.height, + menu: m.nav.mainMenu, + } + case playMenuView: + return playMenuScreen{ + width: m.width, + height: m.height, + menu: m.nav.playMenu, + } + case optionsMenuView: + return optionsMenuScreen{ + width: m.width, + height: m.height, + menu: m.nav.optionsMenu, + } + case seedInputView: + return seedInputScreen{ + width: m.width, + height: m.height, + seed: m.seed, + } + case gameSelectView: + return gameSelectScreen{ + width: m.width, + height: m.height, + list: m.nav.gameSelectList, + detail: m.nav.categoryDetail, + } + case modeSelectView: + return modeSelectScreen{ + width: m.width, + height: m.height, + entry: m.nav.selectedCategory, + list: m.nav.modeSelectList, + } + case continueView: + return continueScreen{ + width: m.width, + height: m.height, + cont: m.cont, + } + case weeklyView: + return weeklyScreen{ + width: m.width, + height: m.height, + weekly: m.weekly, + } + case helpSelectView: + return helpSelectScreen{ + width: m.width, + height: m.height, + help: m.help, + } + case helpDetailView: + return helpDetailScreen{ + width: m.width, + height: m.height, + help: m.help, + } + case statsView: + return statsScreen{ + width: m.width, + height: m.height, + stats: m.stats, + } + case themeSelectView: + return themeSelectScreen{ + width: m.width, + height: m.height, + theme: m.theme, + } + case generatingView: + return generatingScreen{ + width: m.width, + height: m.height, + spinner: m.spinner, + } + default: + return nil + } +} diff --git a/app/screen_layout.go b/app/screen_layout.go new file mode 100644 index 0000000..b5f47ff --- /dev/null +++ b/app/screen_layout.go @@ -0,0 +1,177 @@ +package app + +import ( + "log" + + "github.com/FelineStateMachine/puzzletea/stats" + "github.com/FelineStateMachine/puzzletea/theme" + "github.com/FelineStateMachine/puzzletea/ui" + + "charm.land/bubbles/v2/list" + "charm.land/bubbles/v2/viewport" + "github.com/charmbracelet/glamour" +) + +const ( + helpPanelInsetX = 2 + helpPanelInsetY = 1 + helpPanelHorizontalTrim = 6 + helpPanelVerticalTrim = 8 + categoryPanelChrome = 8 + categoryBodyMaxWidth = 86 + categoryBodyMaxHeight = 16 + categoryMinListWidth = 24 + categoryMaxListWidth = 30 + categoryGapWidth = 2 + categoryDetailTrimX = 6 + categoryDetailTrimY = 4 + categoryStackGapHeight = 1 + categoryMinSideBySideW = 72 +) + +func helpViewportSize(width, height int) (int, int) { + panelWidth := max(width-(helpPanelInsetX*2), 1) + panelHeight := max(height-(helpPanelInsetY*2), 1) + contentWidth := max(panelWidth-helpPanelHorizontalTrim, 1) + contentHeight := max(panelHeight-helpPanelVerticalTrim, 1) + return contentWidth, contentHeight +} + +func helpSelectListSize(width, height int, l list.Model) (int, int) { + contentWidth, contentHeight := helpViewportSize(width, height) + listWidth := min(contentWidth, 64) + listHeight := min(contentHeight, ui.ListHeight(l)) + return listWidth, listHeight +} + +func statsViewportSize(width, height int, cards []stats.Card) (int, int) { + contentWidth, _ := helpViewportSize(width, height) + panelHeight := max(height-(helpPanelInsetY*2), 1) + contentHeight := max(panelHeight-stats.StaticHeight(cards), 1) + return contentWidth, contentHeight +} + +type categoryPickerMetrics struct { + bodyWidth int + bodyHeight int + listWidth int + listHeight int + detailWidth int + detailHeight int + stacked bool +} + +func categoryPickerSize(width, height int) categoryPickerMetrics { + bodyWidth := min(width, categoryBodyMaxWidth) + bodyHeight := min(max(height-categoryPanelChrome, 1), categoryBodyMaxHeight) + + if bodyWidth < categoryMinSideBySideW { + listHeight := min(bodyHeight, categoryPickerListHeight()) + detailHeight := max(bodyHeight-listHeight-categoryStackGapHeight, 1) + if detailHeight == 1 && bodyHeight > 1 { + listHeight = max(bodyHeight-categoryStackGapHeight-detailHeight, 1) + } + return categoryPickerMetrics{ + bodyWidth: bodyWidth, + bodyHeight: bodyHeight, + listWidth: bodyWidth, + listHeight: listHeight, + detailWidth: bodyWidth, + detailHeight: detailHeight, + stacked: true, + } + } + + listWidth := min(categoryMaxListWidth, max(categoryMinListWidth, bodyWidth/3)) + detailWidth := max(bodyWidth-listWidth-categoryGapWidth, 1) + return categoryPickerMetrics{ + bodyWidth: bodyWidth, + bodyHeight: bodyHeight, + listWidth: listWidth, + listHeight: bodyHeight, + detailWidth: detailWidth, + detailHeight: bodyHeight, + } +} + +func selectedCategoryName(item list.Item) string { + entry, ok := selectedCategoryEntry(item) + if !ok { + return "" + } + return entry.Definition.Name +} + +func (m model) updateCategoryDetailViewport() model { + metrics := categoryPickerSize(m.width, m.height) + contentWidth := max(metrics.detailWidth-categoryDetailTrimX, 1) + contentHeight := max(metrics.detailHeight-categoryDetailTrimY, 1) + + if m.nav.categoryDetail.Width() == 0 || m.nav.categoryDetail.Height() == 0 { + m.nav.categoryDetail = viewport.New( + viewport.WithWidth(contentWidth), + viewport.WithHeight(contentHeight), + ) + } + m.nav.categoryDetail.SetWidth(contentWidth) + m.nav.categoryDetail.SetHeight(contentHeight) + m.nav.categoryDetail.FillHeight = true + + entry, ok := selectedCategoryEntry(m.nav.gameSelectList.SelectedItem()) + if !ok { + m.nav.categoryDetail.SetContent("") + return m + } + + m.nav.categoryDetail.SetContent(renderCategoryDetailContent(entry, contentWidth)) + m.nav.categoryDetail.GotoTop() + return m +} + +func (m model) updateHelpDetailViewport() model { + helpWidth, helpHeight := helpViewportSize(m.width, m.height) + palette := theme.Current() + themeKey := helpMarkdownThemeKey(palette) + if m.help.renderer == nil || m.help.rendererWidth != helpWidth || m.help.rendererTheme != themeKey { + renderer, err := glamour.NewTermRenderer( + glamour.WithStyles(helpMarkdownStyle(palette)), + glamour.WithWordWrap(helpWidth), + glamour.WithChromaFormatter("terminal16m"), + ) + if err != nil { + log.Printf("failed to create help renderer: %v", err) + m.help.renderer = nil + m.help.rendererWidth = 0 + m.help.rendererTheme = "" + } else { + m.help.renderer = renderer + m.help.rendererWidth = helpWidth + m.help.rendererTheme = themeKey + } + } + + rendered := m.help.category.Help + if m.help.renderer != nil { + out, err := m.help.renderer.Render(m.help.category.Help) + if err != nil { + log.Printf("failed to render help: %v", err) + } else { + rendered = out + } + } + + m.help.viewport = viewport.New( + viewport.WithWidth(helpWidth), + viewport.WithHeight(helpHeight), + ) + m.help.viewport.SetContent(rendered) + return m +} + +func (m model) updateStatsViewport() model { + statsWidth, statsHeight := statsViewportSize(m.width, m.height, m.stats.cards) + m.stats.viewport.SetWidth(statsWidth) + m.stats.viewport.SetHeight(statsHeight) + m.stats.viewport.SetContent(ui.RenderStatsCardGrid(m.stats.cards, statsWidth)) + return m +} diff --git a/app/screen_menu.go b/app/screen_menu.go new file mode 100644 index 0000000..7927d3a --- /dev/null +++ b/app/screen_menu.go @@ -0,0 +1,152 @@ +package app + +import ( + "github.com/FelineStateMachine/puzzletea/ui" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" +) + +type mainMenuScreen struct { + width int + height int + menu ui.MainMenu +} + +func (s mainMenuScreen) State() viewState { return mainMenuView } + +func (s mainMenuScreen) Resize(width, height int) screenModel { + s.width = width + s.height = height + return s +} + +func (s mainMenuScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) { + updateMainMenuCursor(msg, &s.menu) + keyMsg, ok := msg.(tea.KeyPressMsg) + if !ok || !key.Matches(keyMsg, rootKeys.Enter) { + return s, nil, nil + } + + switch s.menu.SelectedAction() { + case mainMenuActionPlay: + return s, nil, openPlayMenuAction{} + case mainMenuActionStats: + return s, nil, openStatsAction{} + case mainMenuActionOptions: + return s, nil, openOptionsMenuAction{} + case mainMenuActionQuit: + return s, nil, quitAction{} + default: + return s, nil, nil + } +} + +func (s mainMenuScreen) View(notice noticeState) string { + return centerContentWithNotice(s.width, s.height, notice, s.menu.View()) +} + +func (s mainMenuScreen) Apply(m model) model { + m.state = mainMenuView + m.nav.mainMenu = s.menu + return m +} + +type playMenuScreen struct { + width int + height int + menu ui.MainMenu +} + +func (s playMenuScreen) State() viewState { return playMenuView } + +func (s playMenuScreen) Resize(width, height int) screenModel { + s.width = width + s.height = height + return s +} + +func (s playMenuScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) { + updateMainMenuCursor(msg, &s.menu) + keyMsg, ok := msg.(tea.KeyPressMsg) + if !ok { + return s, nil, nil + } + + switch { + case key.Matches(keyMsg, rootKeys.Enter): + switch s.menu.SelectedAction() { + case playMenuActionCreate: + return s, nil, openGameSelectAction{} + case playMenuActionContinue: + return s, nil, openContinueAction{} + case playMenuActionDaily: + return s, nil, openDailyAction{} + case playMenuActionWeekly: + return s, nil, openWeeklyAction{} + case playMenuActionSeeded: + return s, nil, openSeedInputAction{} + } + case key.Matches(keyMsg, rootKeys.Escape): + return s, nil, backAction{target: mainMenuView} + } + + return s, nil, nil +} + +func (s playMenuScreen) View(notice noticeState) string { + return centerContentWithNotice(s.width, s.height, notice, s.menu.ViewAsPanel("Play")) +} + +func (s playMenuScreen) Apply(m model) model { + m.state = playMenuView + m.nav.playMenu = s.menu + return m +} + +type optionsMenuScreen struct { + width int + height int + menu ui.MainMenu +} + +func (s optionsMenuScreen) State() viewState { return optionsMenuView } + +func (s optionsMenuScreen) Resize(width, height int) screenModel { + s.width = width + s.height = height + return s +} + +func (s optionsMenuScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) { + updateMainMenuCursor(msg, &s.menu) + keyMsg, ok := msg.(tea.KeyPressMsg) + if !ok { + return s, nil, nil + } + + switch { + case key.Matches(keyMsg, rootKeys.Enter): + switch s.menu.SelectedAction() { + case optionsMenuActionTheme: + return s, nil, openThemeSelectAction{} + case optionsMenuActionGuides: + return s, nil, openHelpSelectAction{} + } + case key.Matches(keyMsg, rootKeys.Escape): + return s, nil, backAction{target: mainMenuView} + } + + return s, nil, nil +} + +func (s optionsMenuScreen) View(notice noticeState) string { + items := s.menu.RenderItems() + "\n\n" + ui.DimItemStyle().Render("- Dami") + return renderPanelView(s.width, s.height, notice, "Options", items, "↑/↓ navigate • enter select • esc back") +} + +func (s optionsMenuScreen) Apply(m model) model { + m.state = optionsMenuView + m.nav.optionsMenu = s.menu + return m +} diff --git a/app/screen_navigation.go b/app/screen_navigation.go new file mode 100644 index 0000000..042fc60 --- /dev/null +++ b/app/screen_navigation.go @@ -0,0 +1,309 @@ +package app + +import ( + "github.com/FelineStateMachine/puzzletea/registry" + sessionflow "github.com/FelineStateMachine/puzzletea/session" + "github.com/FelineStateMachine/puzzletea/ui" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/list" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" +) + +type seedInputScreen struct { + width int + height int + seed seedState +} + +func (s seedInputScreen) State() viewState { return seedInputView } + +func (s seedInputScreen) Resize(width, height int) screenModel { + s.width = width + s.height = height + s.seed.input.SetWidth(min(width, 48)) + return s +} + +func (s seedInputScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) { + keyMsg, ok := msg.(tea.KeyPressMsg) + if ok { + switch { + case key.Matches(keyMsg, rootKeys.Enter): + if sessionflow.NormalizeSeed(s.seed.input.Value()) != "" { + return s, nil, seedConfirmAction{} + } + return s, nil, nil + case key.Matches(keyMsg, rootKeys.Escape): + return s, nil, backAction{target: playMenuView} + } + } + + m := model{seed: s.seed} + next, cmd := m.handleSeedInputUpdate(msg) + s.seed = next.seed + return s, cmd, nil +} + +func (s seedInputScreen) View(notice noticeState) string { + m := model{ + width: s.width, + height: s.height, + seed: s.seed, + notice: notice, + } + return m.renderPanel( + "Enter Seed", + m.seedInputBody(), + "↑/↓ change field • ←/→ game • enter confirm • esc back", + ) +} + +func (s seedInputScreen) Apply(m model) model { + m.state = seedInputView + m.seed = s.seed + return m +} + +type gameSelectScreen struct { + width int + height int + list list.Model + detail viewport.Model +} + +func (s gameSelectScreen) State() viewState { return gameSelectView } + +func (s gameSelectScreen) Resize(width, height int) screenModel { + m := model{ + width: width, + height: height, + nav: navigationState{ + gameSelectList: s.list, + categoryDetail: s.detail, + }, + } + metrics := categoryPickerSize(width, height) + m.nav.gameSelectList.SetSize(metrics.listWidth, metrics.listHeight) + m = m.updateCategoryDetailViewport() + s.width = width + s.height = height + s.list = m.nav.gameSelectList + s.detail = m.nav.categoryDetail + return s +} + +func (s gameSelectScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) { + keyMsg, ok := msg.(tea.KeyPressMsg) + if ok { + switch { + case s.list.SettingFilter() && s.list.FilterValue() == "" && key.Matches(keyMsg, rootKeys.Enter): + s.list.ResetFilter() + s = s.resizeSelf() + return s, nil, nil + case s.list.SettingFilter() && key.Matches(keyMsg, rootKeys.Enter): + case s.list.FilterState() != list.Unfiltered && key.Matches(keyMsg, rootKeys.Escape): + case key.Matches(keyMsg, rootKeys.Enter): + return s, nil, gameSelectEnterAction{} + case key.Matches(keyMsg, rootKeys.Escape): + return s, nil, backAction{target: playMenuView} + case keyMsg.String() == "pgup": + s.detail.PageUp() + return s, nil, nil + case keyMsg.String() == "pgdown": + s.detail.PageDown() + return s, nil, nil + } + } + + prev := selectedCategoryName(s.list.SelectedItem()) + var cmd tea.Cmd + s.list, cmd = s.list.Update(msg) + if selectedCategoryName(s.list.SelectedItem()) != prev { + s = s.resizeSelf() + } + return s, cmd, nil +} + +func (s gameSelectScreen) View(notice noticeState) string { + m := model{ + width: s.width, + height: s.height, + notice: notice, + nav: navigationState{ + gameSelectList: s.list, + categoryDetail: s.detail, + }, + } + return m.gameSelectViewContent() +} + +func (s gameSelectScreen) Apply(m model) model { + m.state = gameSelectView + m.nav.gameSelectList = s.list + m.nav.categoryDetail = s.detail + return m +} + +func (s gameSelectScreen) resizeSelf() gameSelectScreen { + return s.Resize(s.width, s.height).(gameSelectScreen) +} + +type modeSelectScreen struct { + width int + height int + entry registry.Entry + list list.Model +} + +func (s modeSelectScreen) State() viewState { return modeSelectView } + +func (s modeSelectScreen) Resize(width, height int) screenModel { + s.width = width + s.height = height + s.list.SetSize(min(width, 64), min(height, ui.ListHeight(s.list))) + return s +} + +func (s modeSelectScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) { + keyMsg, ok := msg.(tea.KeyPressMsg) + if ok { + switch { + case key.Matches(keyMsg, rootKeys.Enter): + return s, nil, modeSelectEnterAction{} + case key.Matches(keyMsg, rootKeys.Escape): + return s, nil, backAction{target: gameSelectView} + } + } + + var cmd tea.Cmd + s.list, cmd = s.list.Update(msg) + return s, cmd, nil +} + +func (s modeSelectScreen) View(notice noticeState) string { + m := model{ + width: s.width, + height: s.height, + notice: notice, + nav: navigationState{ + selectedCategory: s.entry, + modeSelectList: s.list, + }, + } + return m.renderPanel( + m.nav.selectedCategory.Definition.Name+" — Select Mode", + m.nav.modeSelectList.View(), + "↑/↓ navigate • enter select • esc back", + ) +} + +func (s modeSelectScreen) Apply(m model) model { + m.state = modeSelectView + m.nav.selectedCategory = s.entry + m.nav.modeSelectList = s.list + return m +} + +type continueScreen struct { + width int + height int + cont continueState +} + +func (s continueScreen) State() viewState { return continueView } + +func (s continueScreen) Resize(width, height int) screenModel { + s.width = width + s.height = height + s.cont.table.SetWidth(min(width, ui.ContinueTableWidth())) + visibleRows := min(len(s.cont.games), ui.MaxTableRows) + s.cont.table.SetHeight(min(height, visibleRows)) + return s +} + +func (s continueScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) { + keyMsg, ok := msg.(tea.KeyPressMsg) + if ok { + switch { + case key.Matches(keyMsg, rootKeys.Enter): + return s, nil, continueEnterAction{} + case key.Matches(keyMsg, rootKeys.Escape): + return s, nil, backAction{target: playMenuView} + } + } + + var cmd tea.Cmd + s.cont.table, cmd = s.cont.table.Update(msg) + return s, cmd, nil +} + +func (s continueScreen) View(notice noticeState) string { + m := model{ + width: s.width, + height: s.height, + notice: notice, + cont: s.cont, + } + return m.renderContinueView() +} + +func (s continueScreen) Apply(m model) model { + m.state = continueView + m.cont = s.cont + return m +} + +type weeklyScreen struct { + width int + height int + weekly weeklyState +} + +func (s weeklyScreen) State() viewState { return weeklyView } + +func (s weeklyScreen) Resize(width, height int) screenModel { + s.width = width + s.height = height + s.weekly.table.SetWidth(min(width, ui.WeeklyTableWidth())) + visibleRows := min(len(s.weekly.rows), ui.MaxTableRows) + s.weekly.table.SetHeight(min(height, visibleRows)) + return s +} + +func (s weeklyScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) { + keyMsg, ok := msg.(tea.KeyPressMsg) + if ok { + switch { + case key.Matches(keyMsg, rootKeys.Escape): + return s, nil, backAction{target: playMenuView} + case key.Matches(keyMsg, rootKeys.Enter): + return s, nil, weeklyEnterAction{} + case keyMsg.String() == "left" || keyMsg.String() == "h": + return s, nil, weeklyShiftAction{delta: -1} + case keyMsg.String() == "right" || keyMsg.String() == "l": + return s, nil, weeklyShiftAction{delta: 1} + } + } + + var cmd tea.Cmd + s.weekly.table, cmd = s.weekly.table.Update(msg) + return s, cmd, nil +} + +func (s weeklyScreen) View(notice noticeState) string { + m := model{ + width: s.width, + height: s.height, + notice: notice, + weekly: s.weekly, + } + return centerContentWithNotice(s.width, s.height, notice, m.weeklyViewContent()) +} + +func (s weeklyScreen) Apply(m model) model { + m.state = weeklyView + m.weekly = s.weekly + return m +} diff --git a/app/screen_support.go b/app/screen_support.go new file mode 100644 index 0000000..367cfcc --- /dev/null +++ b/app/screen_support.go @@ -0,0 +1,256 @@ +package app + +import ( + "github.com/FelineStateMachine/puzzletea/theme" + "github.com/FelineStateMachine/puzzletea/ui" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/list" + "charm.land/bubbles/v2/spinner" + tea "charm.land/bubbletea/v2" +) + +type helpSelectScreen struct { + width int + height int + help helpState +} + +func (s helpSelectScreen) State() viewState { return helpSelectView } + +func (s helpSelectScreen) Resize(width, height int) screenModel { + s.width = width + s.height = height + listWidth, listHeight := helpSelectListSize(width, height, s.help.selectList) + s.help.selectList.SetSize(listWidth, listHeight) + return s +} + +func (s helpSelectScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) { + keyMsg, ok := msg.(tea.KeyPressMsg) + if ok { + switch { + case key.Matches(keyMsg, rootKeys.Enter): + return s, nil, helpSelectEnterAction{} + case key.Matches(keyMsg, rootKeys.Escape): + return s, nil, backAction{target: optionsMenuView} + } + } + + var cmd tea.Cmd + s.help.selectList, cmd = s.help.selectList.Update(msg) + return s, cmd, nil +} + +func (s helpSelectScreen) View(notice noticeState) string { + m := model{ + width: s.width, + height: s.height, + notice: notice, + help: s.help, + } + return m.renderPanel( + "How to Play", + m.help.selectList.View(), + "↑/↓ navigate • enter select • esc back", + ) +} + +func (s helpSelectScreen) Apply(m model) model { + m.state = helpSelectView + m.help.selectList = s.help.selectList + return m +} + +type helpDetailScreen struct { + width int + height int + help helpState +} + +func (s helpDetailScreen) State() viewState { return helpDetailView } + +func (s helpDetailScreen) Resize(width, height int) screenModel { + m := model{ + width: width, + height: height, + help: s.help, + } + m = m.updateHelpDetailViewport() + s.width = width + s.height = height + s.help = m.help + return s +} + +func (s helpDetailScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) { + keyMsg, ok := msg.(tea.KeyPressMsg) + if ok && key.Matches(keyMsg, rootKeys.Escape) { + return s, nil, backAction{target: helpSelectView} + } + + var cmd tea.Cmd + s.help.viewport, cmd = s.help.viewport.Update(msg) + return s, cmd, nil +} + +func (s helpDetailScreen) View(notice noticeState) string { + m := model{ + width: s.width, + height: s.height, + notice: notice, + help: s.help, + } + return m.renderPanel( + m.help.category.Definition.Name+" — Guide", + m.help.viewport.View(), + "↑/↓ scroll • esc back", + ) +} + +func (s helpDetailScreen) Apply(m model) model { + m.state = helpDetailView + m.help = s.help + return m +} + +type statsScreen struct { + width int + height int + stats statsState +} + +func (s statsScreen) State() viewState { return statsView } + +func (s statsScreen) Resize(width, height int) screenModel { + m := model{ + width: width, + height: height, + stats: s.stats, + } + m = m.updateStatsViewport() + s.width = width + s.height = height + s.stats = m.stats + return s +} + +func (s statsScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) { + keyMsg, ok := msg.(tea.KeyPressMsg) + if ok && key.Matches(keyMsg, rootKeys.Escape) { + return s, nil, backAction{target: mainMenuView} + } + + var cmd tea.Cmd + s.stats.viewport, cmd = s.stats.viewport.Update(msg) + return s, cmd, nil +} + +func (s statsScreen) View(notice noticeState) string { + m := model{ + width: s.width, + height: s.height, + notice: notice, + stats: s.stats, + } + return m.renderStatsView() +} + +func (s statsScreen) Apply(m model) model { + m.state = statsView + m.stats = s.stats + return m +} + +type themeSelectScreen struct { + width int + height int + theme themeState +} + +func (s themeSelectScreen) State() viewState { return themeSelectView } + +func (s themeSelectScreen) Resize(width, height int) screenModel { + const maxVisibleItems = 8 + menuW := min(width, 64) + listW := min(menuW, theme.MaxNameLen+4) + s.width = width + s.height = height + s.theme.list.SetSize(listW, min(height, maxVisibleItems*3)) + return s +} + +func (s themeSelectScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) { + keyMsg, ok := msg.(tea.KeyPressMsg) + if ok { + switch { + case s.theme.list.SettingFilter() && s.theme.list.FilterValue() == "" && key.Matches(keyMsg, rootKeys.Enter): + s.theme.list.ResetFilter() + return s, nil, nil + case s.theme.list.SettingFilter() && key.Matches(keyMsg, rootKeys.Enter): + case s.theme.list.FilterState() != list.Unfiltered && key.Matches(keyMsg, rootKeys.Escape): + case key.Matches(keyMsg, rootKeys.Enter): + return s, nil, confirmThemeAction{} + case key.Matches(keyMsg, rootKeys.Escape): + return s, nil, backAction{target: optionsMenuView} + } + } + + prev := s.theme.list.Index() + var cmd tea.Cmd + s.theme.list, cmd = s.theme.list.Update(msg) + if s.theme.list.Index() != prev { + if item, ok := s.theme.list.SelectedItem().(ui.MenuItem); ok { + return s, cmd, previewThemeAction{name: item.ItemTitle} + } + } + return s, cmd, nil +} + +func (s themeSelectScreen) View(notice noticeState) string { + m := model{ + width: s.width, + height: s.height, + notice: notice, + theme: s.theme, + } + return m.themeSelectViewContent() +} + +func (s themeSelectScreen) Apply(m model) model { + m.state = themeSelectView + m.theme = s.theme + return m +} + +type generatingScreen struct { + width int + height int + spinner spinner.Model +} + +func (s generatingScreen) State() viewState { return generatingView } + +func (s generatingScreen) Resize(width, height int) screenModel { + s.width = width + s.height = height + return s +} + +func (s generatingScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) { + var cmd tea.Cmd + s.spinner, cmd = s.spinner.Update(msg) + return s, cmd, nil +} + +func (s generatingScreen) View(notice noticeState) string { + content := s.spinner.View() + " Generating puzzle..." + box := ui.GeneratingFrame().Render(appendNoticeContent(s.width, notice, content)) + return ui.CenterView(s.width, s.height, box) +} + +func (s generatingScreen) Apply(m model) model { + m.state = generatingView + m.spinner = s.spinner + return m +} diff --git a/app/screens_test.go b/app/screens_test.go new file mode 100644 index 0000000..2577a6c --- /dev/null +++ b/app/screens_test.go @@ -0,0 +1,74 @@ +package app + +import ( + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/FelineStateMachine/puzzletea/ui" +) + +func TestMainMenuScreenRoutesByStableAction(t *testing.T) { + screen := mainMenuScreen{ + menu: ui.NewMainMenu([]ui.MenuItem{ + {Action: mainMenuActionPlay, ItemTitle: "Launch", Desc: "custom label"}, + }), + } + + _, _, action := screen.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if _, ok := action.(openPlayMenuAction); !ok { + t.Fatalf("action = %T, want %T", action, openPlayMenuAction{}) + } +} + +func TestPlayMenuScreenRoutesByStableAction(t *testing.T) { + tests := []struct { + name string + item ui.MenuItem + assert func(t *testing.T, action screenAction) + }{ + { + name: "daily", + item: ui.MenuItem{Action: playMenuActionDaily, ItemTitle: "Today", Desc: "custom label"}, + assert: func(t *testing.T, action screenAction) { + t.Helper() + if _, ok := action.(openDailyAction); !ok { + t.Fatalf("action = %T, want %T", action, openDailyAction{}) + } + }, + }, + { + name: "seeded", + item: ui.MenuItem{Action: playMenuActionSeeded, ItemTitle: "Named Seed", Desc: "custom label"}, + assert: func(t *testing.T, action screenAction) { + t.Helper() + if _, ok := action.(openSeedInputAction); !ok { + t.Fatalf("action = %T, want %T", action, openSeedInputAction{}) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + screen := playMenuScreen{ + menu: ui.NewMainMenu([]ui.MenuItem{tt.item}), + } + + _, _, action := screen.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + tt.assert(t, action) + }) + } +} + +func TestOptionsMenuScreenRoutesByStableAction(t *testing.T) { + screen := optionsMenuScreen{ + menu: ui.NewMainMenu([]ui.MenuItem{ + {Action: optionsMenuActionGuides, ItemTitle: "Help Docs", Desc: "custom label"}, + }), + } + + _, _, action := screen.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if _, ok := action.(openHelpSelectAction); !ok { + t.Fatalf("action = %T, want %T", action, openHelpSelectAction{}) + } +} diff --git a/app/seed_input.go b/app/seed_input.go index 569480c..c3806d7 100644 --- a/app/seed_input.go +++ b/app/seed_input.go @@ -75,46 +75,46 @@ func (m model) enterSeedInputView() (model, tea.Cmd) { ti.SetWidth(min(m.width, 48)) options := buildSeedModeOptions(registry.Definitions()) - index := findSeedModeIndex(options, m.nav.lastSeedModeKey) + index := findSeedModeIndex(options, m.seed.lastModeKey) - m.nav.seedInput = ti - m.nav.seedModeOptions = options - m.nav.seedModeIndex = index - m.nav.seedFocus = seedFocusText + m.seed.input = ti + m.seed.modeOptions = options + m.seed.modeIndex = index + m.seed.focus = seedFocusText m.state = seedInputView - return m, m.nav.seedInput.Focus() + return m, m.seed.input.Focus() } func (m model) currentSeedMode() seedModeOption { - if len(m.nav.seedModeOptions) == 0 { + if len(m.seed.modeOptions) == 0 { return seedModeOption{label: randomSeedModeLabel} } - if m.nav.seedModeIndex < 0 || m.nav.seedModeIndex >= len(m.nav.seedModeOptions) { - return m.nav.seedModeOptions[0] + if m.seed.modeIndex < 0 || m.seed.modeIndex >= len(m.seed.modeOptions) { + return m.seed.modeOptions[0] } - return m.nav.seedModeOptions[m.nav.seedModeIndex] + return m.seed.modeOptions[m.seed.modeIndex] } func (m model) moveSeedMode(step int) model { - if len(m.nav.seedModeOptions) == 0 { + if len(m.seed.modeOptions) == 0 { return m } - index := m.nav.seedModeIndex + step + index := m.seed.modeIndex + step for index < 0 { - index += len(m.nav.seedModeOptions) + index += len(m.seed.modeOptions) } - m.nav.seedModeIndex = index % len(m.nav.seedModeOptions) - m.nav.lastSeedModeKey = m.currentSeedMode().key + m.seed.modeIndex = index % len(m.seed.modeOptions) + m.seed.lastModeKey = m.currentSeedMode().key return m } func (m model) setSeedFocus(focus seedInputFocus) (model, tea.Cmd) { - m.nav.seedFocus = focus + m.seed.focus = focus if focus == seedFocusText { - return m, m.nav.seedInput.Focus() + return m, m.seed.input.Focus() } - m.nav.seedInput.Blur() + m.seed.input.Blur() return m, nil } @@ -127,34 +127,34 @@ func (m model) handleSeedInputUpdate(msg tea.Msg) (model, tea.Cmd) { case "down", "j": return m.setSeedFocus(seedFocusMode) case "left", "h": - if m.nav.seedFocus == seedFocusMode { + if m.seed.focus == seedFocusMode { return m.moveSeedMode(-1), nil } case "right", "l": - if m.nav.seedFocus == seedFocusMode { + if m.seed.focus == seedFocusMode { return m.moveSeedMode(1), nil } } } - if m.nav.seedFocus != seedFocusText { + if m.seed.focus != seedFocusText { return m, nil } var cmd tea.Cmd - m.nav.seedInput, cmd = m.nav.seedInput.Update(msg) + m.seed.input, cmd = m.seed.input.Update(msg) return m, cmd } func (m model) seedInputBody() string { - selector := renderSeedModeTitle(m.currentSeedMode().label, m.nav.seedModeIndex) + selector := renderSeedModeTitle(m.currentSeedMode().label, m.seed.modeIndex) modePrefix := " " - if m.nav.seedFocus == seedFocusMode { + if m.seed.focus == seedFocusMode { modePrefix = ui.CursorStyle().Render("> ") } - seedInputView := m.nav.seedInput.View() - if m.nav.seedFocus != seedFocusText { + seedInputView := m.seed.input.View() + if m.seed.focus != seedFocusText { seedInputView = strings.Replace(seedInputView, ">", " ", 1) } diff --git a/app/session_controller.go b/app/session_controller.go new file mode 100644 index 0000000..38eae8a --- /dev/null +++ b/app/session_controller.go @@ -0,0 +1,204 @@ +package app + +import ( + "context" + "math/rand/v2" + + "github.com/FelineStateMachine/puzzletea/game" + sessionflow "github.com/FelineStateMachine/puzzletea/session" + "github.com/FelineStateMachine/puzzletea/store" + + tea "charm.land/bubbletea/v2" +) + +type sessionController struct { + model *model +} + +func newSessionController(m *model) sessionController { + return sessionController{model: m} +} + +func (c sessionController) beginSpawnContext() (context.Context, int64) { + c.cancelActiveSpawn() + c.model.session.spawnJobID++ + jobID := c.model.session.spawnJobID + ctx, cancel := context.WithTimeout(context.Background(), spawnTimeout) + c.model.session.spawnCancel = cancel + c.model.session.generating = true + return ctx, jobID +} + +func (c sessionController) cancelActiveSpawn() { + if c.model.session.spawnCancel != nil { + c.model.session.spawnCancel() + c.model.session.spawnCancel = nil + } + if c.model.session.generating { + c.model.session.spawnJobID++ + } + c.model.session.generating = false + c.model.session.spawn = nil +} + +func (c sessionController) startSpawn(spawner game.Spawner, request spawnRequest) tea.Cmd { + ctx, jobID := c.beginSpawnContext() + c.model.session.spawn = &request + c.model.state = generatingView + *c.model = c.model.clearNotice() + return tea.Batch(c.model.spinner.Tick, spawnCmd(spawner, ctx, jobID)) +} + +func (c sessionController) startSeededSpawn(spawner game.SeededSpawner, rng *rand.Rand, request spawnRequest) tea.Cmd { + ctx, jobID := c.beginSpawnContext() + c.model.session.spawn = &request + c.model.state = generatingView + *c.model = c.model.clearNotice() + return tea.Batch(c.model.spinner.Tick, spawnSeededCmd(spawner, rng, ctx, jobID)) +} + +func (c sessionController) handleSpawnComplete(jobID int64, msg game.SpawnCompleteMsg) tea.Cmd { + if jobID != c.model.session.spawnJobID { + return nil + } + + c.model.session.generating = false + if c.model.session.spawnCancel != nil { + c.model.session.spawnCancel() + c.model.session.spawnCancel = nil + } + request := c.model.session.spawn + c.model.session.spawn = nil + + if c.model.state != generatingView { + return nil + } + + if msg.Err != nil { + *c.model = c.model.setErrorf("Could not generate puzzle: %v", msg.Err) + if request != nil { + c.model.state = request.returnState + } + return nil + } + + if request == nil { + *c.model = c.model.setErrorf("Internal error: missing spawn request metadata") + return nil + } + + c.activateGame(msg.Game.SetTitle(request.name), 0, false, gameOpenOptions{ + returnState: request.exitState, + weeklyInfo: request.weeklyInfo, + }) + *c.model = c.model.clearNotice() + + rec, err := sessionflow.CreateRecord( + c.model.store, + c.model.session.game, + request.name, + request.gameType, + request.modeTitle, + request.run, + ) + if err != nil { + *c.model = c.model.setErrorf("Started puzzle, but could not create a save record: %v", err) + } else { + c.model.session.activeGameID = rec.ID + } + return nil +} + +func (c sessionController) activateGame(g game.Gamer, activeGameID int64, completionSaved bool, options gameOpenOptions) { + g, _ = g.Update(game.HelpToggleMsg{Show: c.model.help.showFull}) + g, _ = g.Update(tea.WindowSizeMsg{Width: c.model.width, Height: c.model.height}) + + c.model.session.game = g + c.model.session.activeGameID = activeGameID + c.model.session.completionSaved = completionSaved + c.model.session.returnState = options.returnState + c.model.session.weeklyAdvance = options.weeklyInfo + c.model.state = gameView +} + +func (c sessionController) importRecord(rec store.GameRecord, options gameOpenOptions) bool { + g, err := sessionflow.ImportRecord(&rec) + if err != nil { + *c.model = c.model.setErrorf("Could not load saved puzzle %q: %v", rec.Name, err) + return false + } + + activeGameID := rec.ID + completionSaved := rec.Status == store.StatusCompleted + if options.readOnly { + activeGameID = 0 + completionSaved = true + } + + c.activateGame(g, activeGameID, completionSaved, options) + *c.model = c.model.clearNotice() + return true +} + +func (c sessionController) updateActiveGame(msg tea.Msg) tea.Cmd { + var cmd tea.Cmd + c.model.session.game, cmd = c.model.session.game.Update(msg) + if c.model.debug.enabled { + c.model.debug.info = c.model.renderDebugInfo() + } + c.persistCompletionIfSolved() + return cmd +} + +func (c sessionController) persistCompletionIfSolved() { + if c.model.session.game == nil || c.model.session.activeGameID == 0 || + c.model.session.completionSaved || !c.model.session.game.IsSolved() { + return + } + + c.model.session.completionSaved = true + saveData, err := c.model.session.game.GetSave() + if err == nil { + if err := c.model.store.UpdateSaveState(c.model.session.activeGameID, string(saveData)); err != nil { + *c.model = c.model.setErrorf("Puzzle completed, but saving the final state failed: %v", err) + } + } else { + *c.model = c.model.setErrorf("Puzzle completed, but reading the final state failed: %v", err) + } + if err := c.model.store.UpdateStatus(c.model.session.activeGameID, store.StatusCompleted); err != nil { + *c.model = c.model.setErrorf("Puzzle completed, but updating completion status failed: %v", err) + } +} + +func (c sessionController) saveCurrentGame(status store.GameStatus) { + if c.model.session.game == nil { + return + } + if c.model.session.activeGameID == 0 { + c.clearActiveGame() + return + } + + saveData, err := c.model.session.game.GetSave() + if err != nil { + *c.model = c.model.setErrorf("Could not save puzzle progress: %v", err) + return + } + if err := c.model.store.UpdateSaveState(c.model.session.activeGameID, string(saveData)); err != nil { + *c.model = c.model.setErrorf("Could not save puzzle progress: %v", err) + } + if !(c.model.session.completionSaved && status != store.StatusCompleted) { + if err := c.model.store.UpdateStatus(c.model.session.activeGameID, status); err != nil { + *c.model = c.model.setErrorf("Could not update puzzle status: %v", err) + } + } + c.clearActiveGame() +} + +func (c sessionController) clearActiveGame() { + c.model.session.activeGameID = 0 + c.model.session.game = nil + c.model.session.completionSaved = false + c.model.session.returnState = mainMenuView + c.model.session.weeklyAdvance = nil +} diff --git a/app/spawn.go b/app/spawn.go index a9b1915..3835152 100644 --- a/app/spawn.go +++ b/app/spawn.go @@ -2,12 +2,10 @@ package app import ( "context" - "log" "math/rand/v2" "time" "github.com/FelineStateMachine/puzzletea/game" - sessionflow "github.com/FelineStateMachine/puzzletea/session" "github.com/FelineStateMachine/puzzletea/store" tea "charm.land/bubbletea/v2" @@ -60,110 +58,17 @@ func spawnSeededCmd(spawner game.SeededSpawner, rng *rand.Rand, ctx context.Cont } } -func (m *model) beginSpawnContext() (context.Context, int64) { - m.cancelActiveSpawn() - m.session.spawnJobID++ - jobID := m.session.spawnJobID - ctx, cancel := context.WithTimeout(context.Background(), spawnTimeout) - m.session.spawnCancel = cancel - m.session.generating = true - return ctx, jobID -} - func (m *model) cancelActiveSpawn() { - if m.session.spawnCancel != nil { - m.session.spawnCancel() - m.session.spawnCancel = nil - } - if m.session.generating { - // Invalidate late completion messages from a canceled job. - m.session.spawnJobID++ - } - m.session.generating = false - m.session.spawn = nil + newSessionController(m).cancelActiveSpawn() } func (m model) handleSpawnComplete(jobID int64, msg game.SpawnCompleteMsg) (tea.Model, tea.Cmd) { - if jobID != m.session.spawnJobID { - return m, nil - } - - m.session.generating = false - if m.session.spawnCancel != nil { - m.session.spawnCancel() - m.session.spawnCancel = nil - } - request := m.session.spawn - m.session.spawn = nil - - // If the user navigated away while generating, discard the result. - if m.state != generatingView { - return m, nil - } - - if msg.Err != nil { - log.Printf("failed to spawn game: %v", msg.Err) - if request != nil { - m.state = request.returnState - } - return m, nil - } - - if request == nil { - log.Printf("missing spawn request metadata") - return m, nil - } - m = m.activateGame(msg.Game.SetTitle(request.name), 0, false, gameOpenOptions{ - returnState: request.exitState, - weeklyInfo: request.weeklyInfo, - }) - - // Capture initial state and create DB record. - rec, err := sessionflow.CreateRecord( - m.store, - m.session.game, - request.name, - request.gameType, - request.modeTitle, - ) - if err != nil { - log.Printf("failed to create game record: %v", err) - } else { - m.session.activeGameID = rec.ID - } - return m, nil + cmd := newSessionController(&m).handleSpawnComplete(jobID, msg) + return m, cmd } // saveCurrentGame saves the current game state to the DB if a game is active. func saveCurrentGame(m model, status store.GameStatus) model { - if m.session.game == nil { - return m - } - if m.session.activeGameID == 0 { - return clearActiveGame(m) - } - saveData, err := m.session.game.GetSave() - if err != nil { - log.Printf("failed to get save data: %v", err) - return m - } - if err := m.store.UpdateSaveState(m.session.activeGameID, string(saveData)); err != nil { - log.Printf("failed to update save state: %v", err) - } - // Don't overwrite a completed status when navigating away. - if !(m.session.completionSaved && status != store.StatusCompleted) { - if err := m.store.UpdateStatus(m.session.activeGameID, status); err != nil { - log.Printf("failed to update game status: %v", err) - } - } - return clearActiveGame(m) -} - -func clearActiveGame(m model) model { - m.session.activeGameID = 0 - m.session.game = nil - m.session.completionSaved = false - m.session.returnState = mainMenuView - m.session.weeklyAdvance = nil + newSessionController(&m).saveCurrentGame(status) return m } diff --git a/app/spawn_test.go b/app/spawn_test.go index e6183df..c64cc1d 100644 --- a/app/spawn_test.go +++ b/app/spawn_test.go @@ -64,3 +64,30 @@ func TestGeneratingEscapeCancelsActiveSpawn(t *testing.T) { t.Fatal("expected spawnCancel to be cleared") } } + +func TestHandleSpawnCompleteSurfacingErrors(t *testing.T) { + m := model{ + state: generatingView, + session: sessionState{ + generating: true, + spawnJobID: 9, + spawn: &spawnRequest{ + returnState: playMenuView, + }, + }, + } + + next, _ := m.handleSpawnComplete(9, game.SpawnCompleteMsg{Err: assertiveError("boom")}) + got := next.(model) + + if got.state != playMenuView { + t.Fatalf("state = %d, want %d", got.state, playMenuView) + } + if got.notice.message == "" { + t.Fatal("expected spawn error notice to be populated") + } +} + +type assertiveError string + +func (e assertiveError) Error() string { return string(e) } diff --git a/app/state_view.go b/app/state_view.go new file mode 100644 index 0000000..4ef837e --- /dev/null +++ b/app/state_view.go @@ -0,0 +1,52 @@ +package app + +import ( + "github.com/FelineStateMachine/puzzletea/ui" + + "charm.land/lipgloss/v2" +) + +func (m model) renderContinueView() string { + if len(m.cont.games) == 0 { + return m.renderPanel("Saved Games", "No saved games yet.", "esc back") + } + + footer := "↑/↓ navigate • enter resume • esc back" + if pg := ui.TablePagination(m.cont.table); pg != "" { + footer = pg + " " + footer + } + return m.renderPanel("Saved Games", m.cont.table.View(), footer) +} + +func (m model) renderGameView() string { + if m.session.game == nil { + return "" + } + + gameView := lipgloss.NewStyle().MaxWidth(m.width).Render(m.session.game.View()) + centered := gameView + if m.debug.enabled { + debugInfo := lipgloss.NewStyle().MaxWidth(m.width).Render( + ui.DebugStyle().Render(m.debug.info), + ) + centered = lipgloss.JoinVertical(lipgloss.Center, gameView, debugInfo) + } + return ui.CenterView(m.width, m.height, m.appendNotice(centered)) +} + +func (m model) renderStatsView() string { + statsWidth, _ := statsViewportSize(m.width, m.height, m.stats.cards) + var statsBody string + if len(m.stats.cards) == 0 { + statsBody = m.stats.viewport.View() + } else { + banner := ui.RenderStatsBanner(m.stats.profile, statsWidth) + statsBody = lipgloss.JoinVertical(lipgloss.Left, + banner, + "", + m.stats.viewport.View(), + ) + } + statsBody = lipgloss.NewStyle().Width(statsWidth).Render(statsBody) + return m.renderPanel("Stats", statsBody, "↑/↓ scroll • esc back") +} diff --git a/app/update.go b/app/update.go index 406a84d..a3ac747 100644 --- a/app/update.go +++ b/app/update.go @@ -1,392 +1,143 @@ package app import ( - "log" "time" - "github.com/FelineStateMachine/puzzletea/config" - "github.com/FelineStateMachine/puzzletea/daily" "github.com/FelineStateMachine/puzzletea/game" - "github.com/FelineStateMachine/puzzletea/registry" - "github.com/FelineStateMachine/puzzletea/resolve" - sessionflow "github.com/FelineStateMachine/puzzletea/session" - "github.com/FelineStateMachine/puzzletea/stats" "github.com/FelineStateMachine/puzzletea/store" "github.com/FelineStateMachine/puzzletea/theme" "github.com/FelineStateMachine/puzzletea/ui" "charm.land/bubbles/v2/key" - "charm.land/bubbles/v2/list" - "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/glamour" ) -const ( - helpPanelInsetX = 2 - helpPanelInsetY = 1 - helpPanelHorizontalTrim = 6 - helpPanelVerticalTrim = 8 - categoryPanelChrome = 8 - categoryBodyMaxWidth = 86 - categoryBodyMaxHeight = 16 - categoryMinListWidth = 24 - categoryMaxListWidth = 30 - categoryGapWidth = 2 - categoryDetailTrimX = 6 - categoryDetailTrimY = 4 - categoryStackGapHeight = 1 - categoryMinSideBySideW = 72 -) - -func helpViewportSize(width, height int) (int, int) { - panelWidth := max(width-(helpPanelInsetX*2), 1) - panelHeight := max(height-(helpPanelInsetY*2), 1) - contentWidth := max(panelWidth-helpPanelHorizontalTrim, 1) - contentHeight := max(panelHeight-helpPanelVerticalTrim, 1) - return contentWidth, contentHeight -} - -func helpSelectListSize(width, height int, l list.Model) (int, int) { - contentWidth, contentHeight := helpViewportSize(width, height) - listWidth := min(contentWidth, 64) - listHeight := min(contentHeight, ui.ListHeight(l)) - return listWidth, listHeight -} - -func statsViewportSize(width, height int, cards []stats.Card) (int, int) { - contentWidth, _ := helpViewportSize(width, height) - panelHeight := max(height-(helpPanelInsetY*2), 1) - contentHeight := max(panelHeight-stats.StaticHeight(cards), 1) - return contentWidth, contentHeight -} - -type categoryPickerMetrics struct { - bodyWidth int - bodyHeight int - listWidth int - listHeight int - detailWidth int - detailHeight int - stacked bool -} - -func categoryPickerSize(width, height int) categoryPickerMetrics { - bodyWidth := min(width, categoryBodyMaxWidth) - bodyHeight := min(max(height-categoryPanelChrome, 1), categoryBodyMaxHeight) - - if bodyWidth < categoryMinSideBySideW { - listHeight := min(bodyHeight, categoryPickerListHeight()) - detailHeight := max(bodyHeight-listHeight-categoryStackGapHeight, 1) - if detailHeight == 1 && bodyHeight > 1 { - listHeight = max(bodyHeight-categoryStackGapHeight-detailHeight, 1) - } - return categoryPickerMetrics{ - bodyWidth: bodyWidth, - bodyHeight: bodyHeight, - listWidth: bodyWidth, - listHeight: listHeight, - detailWidth: bodyWidth, - detailHeight: detailHeight, - stacked: true, +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case spawnCompleteMsg: + next, cmd := m.handleSpawnComplete(msg.jobID, msg.result) + return next, cmd + case game.SpawnCompleteMsg: + next, cmd := m.handleSpawnComplete(m.session.spawnJobID, msg) + return next, cmd + case tea.WindowSizeMsg: + m = m.handleWindowSize(msg) + if m.state != gameView { + return m, nil } } - listWidth := min(categoryMaxListWidth, max(categoryMinListWidth, bodyWidth/3)) - detailWidth := max(bodyWidth-listWidth-categoryGapWidth, 1) - return categoryPickerMetrics{ - bodyWidth: bodyWidth, - bodyHeight: bodyHeight, - listWidth: listWidth, - listHeight: bodyHeight, - detailWidth: detailWidth, - detailHeight: bodyHeight, - } -} - -func selectedCategoryName(item list.Item) string { - entry, ok := selectedCategoryEntry(item) - if !ok { - return "" - } - return entry.Definition.Name -} - -func activeFilterList(m model) *list.Model { - switch m.state { - case gameSelectView: - return &m.nav.gameSelectList - case themeSelectView: - return &m.theme.list - default: - return nil - } -} - -func (m model) updateCategoryDetailViewport() model { - metrics := categoryPickerSize(m.width, m.height) - contentWidth := max(metrics.detailWidth-categoryDetailTrimX, 1) - contentHeight := max(metrics.detailHeight-categoryDetailTrimY, 1) - - if m.nav.categoryDetail.Width() == 0 || m.nav.categoryDetail.Height() == 0 { - m.nav.categoryDetail = viewport.New( - viewport.WithWidth(contentWidth), - viewport.WithHeight(contentHeight), - ) + next, cmd, handled := m.handleGlobalKey(msg) + if handled { + return next, cmd } - m.nav.categoryDetail.SetWidth(contentWidth) - m.nav.categoryDetail.SetHeight(contentHeight) - m.nav.categoryDetail.FillHeight = true + m = next - entry, ok := selectedCategoryEntry(m.nav.gameSelectList.SelectedItem()) - if !ok { - m.nav.categoryDetail.SetContent("") - return m + if m.state == gameView { + gameCmd := newSessionController(&m).updateActiveGame(msg) + return m, gameCmd } - m.nav.categoryDetail.SetContent(renderCategoryDetailContent(entry, contentWidth)) - m.nav.categoryDetail.GotoTop() - return m -} - -func (m model) updateHelpDetailViewport() model { - helpWidth, helpHeight := helpViewportSize(m.width, m.height) - palette := theme.Current() - themeKey := helpMarkdownThemeKey(palette) - if m.help.renderer == nil || m.help.rendererWidth != helpWidth || m.help.rendererTheme != themeKey { - renderer, err := glamour.NewTermRenderer( - glamour.WithStyles(helpMarkdownStyle(palette)), - glamour.WithWordWrap(helpWidth), - glamour.WithChromaFormatter("terminal16m"), - ) - if err != nil { - log.Printf("failed to create help renderer: %v", err) - m.help.renderer = nil - m.help.rendererWidth = 0 - m.help.rendererTheme = "" - } else { - m.help.renderer = renderer - m.help.rendererWidth = helpWidth - m.help.rendererTheme = themeKey - } + screen := m.activeScreen() + if screen == nil { + return m, nil } - rendered := m.help.category.Help - if m.help.renderer != nil { - out, err := m.help.renderer.Render(m.help.category.Help) - if err != nil { - log.Printf("failed to render help: %v", err) - } else { - rendered = out - } + nextScreen, screenCmd, action := screen.Update(msg) + m = nextScreen.Apply(m) + if action == nil { + return m, screenCmd } - m.help.viewport = viewport.New( - viewport.WithWidth(helpWidth), - viewport.WithHeight(helpHeight), - ) - m.help.viewport.SetContent(rendered) - return m + next, actionCmd := m.handleScreenAction(action) + return next, tea.Batch(screenCmd, actionCmd) } -func (m model) updateStatsViewport() model { - statsWidth, statsHeight := statsViewportSize(m.width, m.height, m.stats.cards) - m.stats.viewport.SetWidth(statsWidth) - m.stats.viewport.SetHeight(statsHeight) - m.stats.viewport.SetContent(ui.RenderStatsCardGrid(m.stats.cards, statsWidth)) - return m -} - -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - - switch msg := msg.(type) { - case spawnCompleteMsg: - return m.handleSpawnComplete(msg.jobID, msg.result) - case game.SpawnCompleteMsg: - // Backward compatibility for callers still using the old message type. - return m.handleSpawnComplete(m.session.spawnJobID, msg) - case tea.WindowSizeMsg: - m = m.handleWindowSize(msg) - case tea.KeyPressMsg: - next, keyCmd, done := m.handleGlobalKey(msg) - if done { - return next, keyCmd - } - m = next - } - - switch m.state { - case mainMenuView: - updateMainMenuCursor(msg, &m.nav.mainMenu) - case playMenuView: - updateMainMenuCursor(msg, &m.nav.playMenu) - case optionsMenuView: - updateMainMenuCursor(msg, &m.nav.optionsMenu) - case seedInputView: - m, cmd = m.handleSeedInputUpdate(msg) - case generatingView: - m.spinner, cmd = m.spinner.Update(msg) - case gameView: - m.session.game, cmd = m.session.game.Update(msg) - if m.debug.enabled { - m.debug.info = m.renderDebugInfo() - } - m = m.persistCompletionIfSolved() - case gameSelectView: - prev := selectedCategoryName(m.nav.gameSelectList.SelectedItem()) - m.nav.gameSelectList, cmd = m.nav.gameSelectList.Update(msg) - if selectedCategoryName(m.nav.gameSelectList.SelectedItem()) != prev { - m = m.updateCategoryDetailViewport() - } - case modeSelectView: - m.nav.modeSelectList, cmd = m.nav.modeSelectList.Update(msg) - case continueView: - m.nav.continueTable, cmd = m.nav.continueTable.Update(msg) - case weeklyView: - m.nav.weeklyTable, cmd = m.nav.weeklyTable.Update(msg) - case helpSelectView: - m.nav.helpSelectList, cmd = m.nav.helpSelectList.Update(msg) - case helpDetailView: - m.help.viewport, cmd = m.help.viewport.Update(msg) - case statsView: - m.stats.viewport, cmd = m.stats.viewport.Update(msg) - case themeSelectView: - prev := m.theme.list.Index() - m.theme.list, cmd = m.theme.list.Update(msg) - if m.theme.list.Index() != prev { - if item, ok := m.theme.list.SelectedItem().(ui.MenuItem); ok { - _ = theme.Apply(item.ItemTitle) - ui.UpdateThemeListStyles(&m.theme.list) - } - } +func (m model) resizeActiveScreen() model { + screen := m.activeScreen() + if screen == nil { + return m } - - return m, cmd + return screen.Resize(m.width, m.height).Apply(m) } func (m model) handleWindowSize(msg tea.WindowSizeMsg) model { m.width = msg.Width m.height = msg.Height - menuW := min(m.width, 64) - metrics := categoryPickerSize(m.width, m.height) - m.nav.gameSelectList.SetSize(metrics.listWidth, metrics.listHeight) - m = m.updateCategoryDetailViewport() - if m.state == seedInputView { - m.nav.seedInput.SetWidth(min(m.width, 48)) - } - if m.state == modeSelectView { - m.nav.modeSelectList.SetSize(menuW, min(m.height, ui.ListHeight(m.nav.modeSelectList))) - } - if m.state == continueView { - m.nav.continueTable.SetWidth(m.width) - visibleRows := min(len(m.nav.continueGames), ui.MaxTableRows) - m.nav.continueTable.SetHeight(min(m.height, visibleRows)) - } - if m.state == weeklyView { - m = m.refreshWeeklyBrowser() - } - if m.state == helpSelectView { - listWidth, listHeight := helpSelectListSize(m.width, m.height, m.nav.helpSelectList) - m.nav.helpSelectList.SetSize(listWidth, listHeight) - } - if m.state == helpDetailView { - m = m.updateHelpDetailViewport() - } - if m.state == themeSelectView { - const maxVisibleItems = 8 - listW := min(menuW, theme.MaxNameLen+4) - m.theme.list.SetSize(listW, min(m.height, maxVisibleItems*3)) - } - if m.state == statsView { - m = m.updateStatsViewport() + if m.state == gameView { + return m } - return m + return m.resizeActiveScreen() } -func (m model) handleGlobalKey(msg tea.KeyPressMsg) (model, tea.Cmd, bool) { +func (m model) handleGlobalKey(msg tea.Msg) (model, tea.Cmd, bool) { + keyMsg, ok := msg.(tea.KeyPressMsg) + if !ok { + return m, nil, false + } + if m.state == generatingView { switch { - case key.Matches(msg, rootKeys.Escape): + case key.Matches(keyMsg, rootKeys.Escape): returnState := m.activeSpawnReturnState() m.cancelActiveSpawn() m.state = returnState - return m, nil, true - case key.Matches(msg, rootKeys.Quit): + return m.resizeActiveScreen(), nil, true + case key.Matches(keyMsg, rootKeys.Quit): return m, tea.Quit, true } return m, nil, true } - if l := activeFilterList(m); l != nil { + if m.state == gameView { switch { - case l.SettingFilter() && l.FilterValue() == "" && key.Matches(msg, rootKeys.Enter): - l.ResetFilter() - if m.state == gameSelectView { - m = m.updateCategoryDetailViewport() + case key.Matches(keyMsg, rootKeys.Enter): + next, cmd, handled := m.advanceSolvedWeekly() + if handled { + return next, cmd, true + } + case key.Matches(keyMsg, rootKeys.Escape): + returnState := m.session.returnState + m = saveCurrentGame(m, store.StatusInProgress) + m.state = returnState + if returnState == weeklyView { + m = m.refreshWeeklyBrowser() + } + m.debug.enabled = false + return m, nil, true + case key.Matches(keyMsg, rootKeys.Quit): + m = saveCurrentGame(m, store.StatusAbandoned) + return m, tea.Quit, true + case key.Matches(keyMsg, rootKeys.Debug): + m.debug.enabled = !m.debug.enabled + return m, nil, true + case key.Matches(keyMsg, rootKeys.FullHelp): + m.help.showFull = !m.help.showFull + if m.session.game != nil { + m.session.game, _ = m.session.game.Update(game.HelpToggleMsg{Show: m.help.showFull}) + } + return m, nil, true + case key.Matches(keyMsg, rootKeys.ResetGame): + if m.session.game != nil { + m.session.game = m.session.game.Reset() } return m, nil, true - case l.SettingFilter() && key.Matches(msg, rootKeys.Enter): - return m, nil, false - case l.FilterState() != list.Unfiltered && key.Matches(msg, rootKeys.Escape): - return m, nil, false } + return m, nil, false } switch { - case m.state == weeklyView && (msg.String() == "left" || msg.String() == "h"): - m = m.moveWeeklyWeek(-1) - return m, nil, true - case m.state == weeklyView && (msg.String() == "right" || msg.String() == "l"): - m = m.moveWeeklyWeek(1) - return m, nil, true - case m.state == gameView && key.Matches(msg, rootKeys.Enter): - next, cmd, handled := m.advanceSolvedWeekly() - if handled { - return next, cmd, true - } - case m.state == gameSelectView && msg.String() == "pgup": - m.nav.categoryDetail.PageUp() - return m, nil, true - case m.state == gameSelectView && msg.String() == "pgdown": - m.nav.categoryDetail.PageDown() - return m, nil, true - case m.state == gameView && key.Matches(msg, rootKeys.Escape): - returnState := m.session.returnState - m = saveCurrentGame(m, store.StatusInProgress) - m.state = returnState - if returnState == weeklyView { - m = m.refreshWeeklyBrowser() - } - m.debug.enabled = false - return m, nil, true - case key.Matches(msg, rootKeys.Enter): - if m.state != gameView { - next, cmd := m.handleEnter() - return next.(model), cmd, true - } - case key.Matches(msg, rootKeys.Escape): - if m.state != gameView { - next, cmd := m.handleEscape() - return next.(model), cmd, true - } - case key.Matches(msg, rootKeys.Quit): - m = saveCurrentGame(m, store.StatusAbandoned) + case key.Matches(keyMsg, rootKeys.Quit): return m, tea.Quit, true - case key.Matches(msg, rootKeys.Debug): + case key.Matches(keyMsg, rootKeys.Debug): m.debug.enabled = !m.debug.enabled - case key.Matches(msg, rootKeys.FullHelp): + return m, nil, true + case key.Matches(keyMsg, rootKeys.FullHelp): m.help.showFull = !m.help.showFull - if m.state == gameView && m.session.game != nil { - m.session.game, _ = m.session.game.Update(game.HelpToggleMsg{Show: m.help.showFull}) - } - case key.Matches(msg, rootKeys.ResetGame): - if m.state == gameView && m.session.game != nil { - m.session.game = m.session.game.Reset() - } + return m, nil, true + default: + return m, nil, false } - - return m, nil, false } func (m model) activeSpawnReturnState() viewState { @@ -396,418 +147,79 @@ func (m model) activeSpawnReturnState() viewState { return m.session.spawn.returnState } -func (m model) persistCompletionIfSolved() model { - if m.session.game == nil || m.session.activeGameID == 0 || - m.session.completionSaved || !m.session.game.IsSolved() { - return m - } - - m.session.completionSaved = true - saveData, err := m.session.game.GetSave() - if err == nil { - if err := m.store.UpdateSaveState(m.session.activeGameID, string(saveData)); err != nil { - log.Printf("failed to save completion state: %v", err) - } - } - if err := m.store.UpdateStatus(m.session.activeGameID, store.StatusCompleted); err != nil { - log.Printf("failed to mark game completed: %v", err) - } - return m -} - -func updateMainMenuCursor(msg tea.Msg, menu *ui.MainMenu) { - keyMsg, ok := msg.(tea.KeyPressMsg) - if !ok { - return - } - switch keyMsg.String() { - case "up", "k": - menu.CursorUp() - case "down", "j": - menu.CursorDown() - } -} - -func (m model) handleEnter() (tea.Model, tea.Cmd) { - switch m.state { - case mainMenuView: - return m.handleMainMenuEnter() - case playMenuView: - return m.handlePlayMenuEnter() - case optionsMenuView: - return m.handleOptionsMenuEnter() - case seedInputView: - return m.handleSeedConfirm() - case gameSelectView: - return m.handleGameSelectEnter() - case modeSelectView: - return m.handleModeSelectEnter() - case continueView: - return m.handleContinueEnter() - case weeklyView: - return m.handleWeeklyEnter() - case helpSelectView: - return m.handleHelpSelectEnter() - case themeSelectView: - return m.handleThemeConfirm() - } - return m, nil -} - -func (m model) handleMainMenuEnter() (tea.Model, tea.Cmd) { - item := m.nav.mainMenu.Selected() - switch item.Title() { - case "Play": +func (m model) handleScreenAction(action screenAction) (model, tea.Cmd) { + switch action := action.(type) { + case openPlayMenuAction: m.nav.playMenu = ui.NewMainMenu(buildPlayMenuItems(time.Now(), m.currentWeeklyMenuIndex())) m.state = playMenuView - case "Stats": - return m.handleStatsEnter() - case "Options": + m = m.clearNotice() + return m.resizeActiveScreen(), nil + case openStatsAction: + return asModel(m.handleStatsEnter()) + case openOptionsMenuAction: m.nav.optionsMenu = ui.NewMainMenu(optionsMenuItems) m.state = optionsMenuView - case "Quit": + m = m.clearNotice() + return m.resizeActiveScreen(), nil + case quitAction: return m, tea.Quit - } - return m, nil -} - -func (m model) currentWeeklyMenuIndex() int { - if m.store == nil { - return 1 - } - - year, week := time.Now().ISOWeek() - highestCompleted, err := m.store.GetCurrentWeeklyHighestCompletedIndex(year, week) - if err != nil { - return 1 - } - if highestCompleted >= weeklyEntryCount { - return weeklyEntryCount - } - if highestCompleted < 1 { - return 1 - } - return highestCompleted + 1 -} - -func (m model) handlePlayMenuEnter() (tea.Model, tea.Cmd) { - item := m.nav.playMenu.Selected() - switch item.Title() { - case "Create": + case openGameSelectAction: m.state = gameSelectView m = m.updateCategoryDetailViewport() - case "Continue": - m.nav.continueTable, m.nav.continueGames = ui.InitContinueTable(m.store, m.height) + m = m.clearNotice() + return m.resizeActiveScreen(), nil + case openContinueAction: + m.cont.table, m.cont.games = ui.InitContinueTable(m.store, m.height) m.state = continueView - case "Daily": - return m.handleDailyPuzzle() - case "Weekly": - return m.enterWeeklyView() - case "Seeded": + m = m.clearNotice() + return m.resizeActiveScreen(), nil + case openDailyAction: + return asModel(m.handleDailyPuzzle()) + case openWeeklyAction: + return asModel(m.enterWeeklyView()) + case openSeedInputAction: return m.enterSeedInputView() - } - return m, nil -} - -func (m model) handleOptionsMenuEnter() (tea.Model, tea.Cmd) { - item := m.nav.optionsMenu.Selected() - switch item.Title() { - case "Theme": - return m.handleThemeEnter() - case "Guides": - m.nav.helpSelectList = ui.InitList(gameCategoryItems, "How to Play") - listWidth, listHeight := helpSelectListSize(m.width, m.height, m.nav.helpSelectList) - m.nav.helpSelectList.SetSize(listWidth, listHeight) - m.state = helpSelectView - } - return m, nil -} - -func (m model) handleSeedConfirm() (tea.Model, tea.Cmd) { - seed := sessionflow.NormalizeSeed(m.nav.seedInput.Value()) - if seed == "" { - return m, nil - } - - selectedMode := m.currentSeedMode() - name := sessionflow.SeededName(seed) - if selectedMode.key != "" { - name = sessionflow.SeededNameForGame(seed, selectedMode.gameType) - } - - rec, err := m.store.GetDailyGame(name) - if err != nil { - log.Printf("failed to check seeded game: %v", err) - return m, nil - } - if rec != nil { - var resumed bool - m, resumed = m.importAndActivateRecord(*rec) - if resumed { - if err := sessionflow.ResumeAbandonedDeterministicRecord(m.store, rec); err != nil { - log.Printf("%v", err) - } - } - return m, nil - } - - var spawner game.SeededSpawner - var gameType string - modeTitle := "" - if selectedMode.key == "" { - spawner, gameType, modeTitle, err = resolve.SeededMode(seed, registry.Entries()) - if err != nil { - log.Printf("failed to select seeded mode: %v", err) - return m, nil - } - } else { - spawner, gameType, modeTitle, err = resolve.SeededModeForGame(seed, selectedMode.gameType, registry.Entries()) - if err != nil { - log.Printf("failed to select seeded mode for %s: %v", selectedMode.gameType, err) - return m, nil - } - } - - rng := resolve.RNGFromString(seed) - ctx, jobID := m.beginSpawnContext() - m.session.spawn = &spawnRequest{ - source: spawnSourceSeed, - name: name, - gameType: gameType, - modeTitle: modeTitle, - returnState: playMenuView, - exitState: mainMenuView, - } - m.state = generatingView - return m, tea.Batch(m.spinner.Tick, spawnSeededCmd(spawner, rng, ctx, jobID)) -} - -func (m model) handleDailyPuzzle() (tea.Model, tea.Cmd) { - today := time.Now() - name := daily.Name(today) - - rec, err := m.store.GetDailyGame(name) - if err != nil { - log.Printf("failed to check daily game: %v", err) - return m, nil - } - if rec != nil { - var resumed bool - m, resumed = m.importAndActivateRecord(*rec) - if resumed { - if err := sessionflow.ResumeAbandonedDeterministicRecord(m.store, rec); err != nil { - log.Printf("%v", err) - } - } - return m, nil - } - - spawner, gameType, modeTitle := daily.Mode(today) - if spawner == nil { - log.Printf("no daily mode available for %s", today.Format("2006-01-02")) - return m, nil - } - - rng := daily.RNG(today) - ctx, jobID := m.beginSpawnContext() - m.session.spawn = &spawnRequest{ - source: spawnSourceDaily, - name: name, - gameType: gameType, - modeTitle: modeTitle, - returnState: playMenuView, - exitState: mainMenuView, - } - m.state = generatingView - return m, tea.Batch(m.spinner.Tick, spawnSeededCmd(spawner, rng, ctx, jobID)) -} - -func (m model) handleGameSelectEnter() (tea.Model, tea.Cmd) { - entry, ok := selectedCategoryEntry(m.nav.gameSelectList.SelectedItem()) - if !ok { - return m, nil - } - m.nav.selectedCategory = entry - m.nav.modeSelectList = ui.InitList(buildModeDisplayItems(entry), entry.Definition.Name+" - Select Mode") - m.nav.modeSelectList.SetSize(min(m.width, 64), min(m.height, ui.ListHeight(m.nav.modeSelectList))) - m.state = modeSelectView - return m, nil -} - -func (m model) handleModeSelectEnter() (tea.Model, tea.Cmd) { - item := unwrapModeDisplayItem(m.nav.modeSelectList.SelectedItem()) - mode, ok := item.(registry.ModeEntry) - if !ok { - return m, nil - } - m.nav.selectedModeTitle = mode.Definition.Title - ctx, jobID := m.beginSpawnContext() - m.session.spawn = &spawnRequest{ - source: spawnSourceNormal, - name: sessionflow.GenerateUniqueName(m.store), - gameType: m.nav.selectedCategory.Definition.Name, - modeTitle: m.nav.selectedModeTitle, - returnState: modeSelectView, - exitState: mainMenuView, - } - m.state = generatingView - return m, tea.Batch(m.spinner.Tick, spawnCmd(mode.Spawner, ctx, jobID)) -} - -func (m model) handleContinueEnter() (tea.Model, tea.Cmd) { - idx := m.nav.continueTable.Cursor() - if idx < 0 || idx >= len(m.nav.continueGames) { - return m, nil - } - rec := m.nav.continueGames[idx] - m, _ = m.importAndActivateRecord(rec) - return m, nil -} - -func (m model) handleHelpSelectEnter() (tea.Model, tea.Cmd) { - entry, ok := selectedCategoryEntry(m.nav.helpSelectList.SelectedItem()) - if !ok { - return m, nil - } - m.help.category = entry - m = m.updateHelpDetailViewport() - m.state = helpDetailView - return m, nil -} - -func (m model) handleEscape() (tea.Model, tea.Cmd) { - switch m.state { - case playMenuView, optionsMenuView, statsView: - m.state = mainMenuView - case seedInputView, gameSelectView, continueView, weeklyView: - m.state = playMenuView - case generatingView: - returnState := m.activeSpawnReturnState() - m.cancelActiveSpawn() - m.state = returnState - case modeSelectView: - m.state = gameSelectView - m = m.updateCategoryDetailViewport() - case helpDetailView: - m.state = helpSelectView - case helpSelectView, themeSelectView: + case backAction: if m.state == themeSelectView { _ = theme.Apply(m.theme.previous) } - m.state = optionsMenuView - } - return m, nil -} - -func (m model) handleThemeEnter() (tea.Model, tea.Cmd) { - m.theme.previous = m.cfg.Theme - - names := theme.ThemeNames() - items := make([]list.Item, len(names)) - for i, n := range names { - desc := "dark theme" - if n == theme.DefaultThemeName { - desc = "built-in earth-tone palette" - } else if t := theme.LookupTheme(n); t != nil && !t.Meta.IsDark { - desc = "light theme" - } - items[i] = ui.MenuItem{ItemTitle: n, Desc: desc} - } - - const maxVisibleItems = 8 - listH := min(m.height, maxVisibleItems*3) - listW := min(m.width, theme.MaxNameLen+4) - - m.theme.list = ui.InitThemeList(items, listW, listH) - for i, item := range items { - if mi, ok := item.(ui.MenuItem); ok && mi.ItemTitle == m.theme.previous { - m.theme.list.Select(i) - break - } - } - if m.theme.previous == "" { - m.theme.list.Select(0) - } - - m.state = themeSelectView - return m, nil -} - -func (m model) handleThemeConfirm() (tea.Model, tea.Cmd) { - item, ok := m.theme.list.SelectedItem().(ui.MenuItem) - if !ok { + m.state = action.target + return m.resizeActiveScreen(), nil + case gameSelectEnterAction: + return asModel(m.handleGameSelectEnter()) + case modeSelectEnterAction: + return asModel(m.handleModeSelectEnter()) + case continueEnterAction: + return asModel(m.handleContinueEnter()) + case weeklyShiftAction: + m = m.moveWeeklyWeek(action.delta) + return m, nil + case weeklyEnterAction: + return asModel(m.handleWeeklyEnter()) + case helpSelectEnterAction: + return asModel(m.handleHelpSelectEnter()) + case openThemeSelectAction: + return asModel(m.handleThemeEnter()) + case openHelpSelectAction: + m.help.selectList = ui.InitList(gameCategoryItems, "How to Play") + listWidth, listHeight := helpSelectListSize(m.width, m.height, m.help.selectList) + m.help.selectList.SetSize(listWidth, listHeight) + m.state = helpSelectView + m = m.clearNotice() + return m.resizeActiveScreen(), nil + case previewThemeAction: + _ = theme.Apply(action.name) + ui.UpdateThemeListStyles(&m.theme.list) + return m, nil + case confirmThemeAction: + return asModel(m.handleThemeConfirm()) + case seedConfirmAction: + return asModel(m.handleSeedConfirm()) + default: return m, nil } - - themeName := item.ItemTitle - if themeName == theme.DefaultThemeName { - themeName = "" - } - - _ = theme.Apply(item.ItemTitle) - m.cfg.Theme = themeName - if err := m.cfg.Save(config.DefaultPath()); err != nil { - log.Printf("failed to save config: %v", err) - } - - m.state = mainMenuView - return m, nil } -func (m model) handleStatsEnter() (tea.Model, tea.Cmd) { - catStats, err := m.store.GetCategoryStats() - if err != nil { - log.Printf("failed to get category stats: %v", err) - return m, nil - } - modeStats, err := m.store.GetModeStats() - if err != nil { - log.Printf("failed to get mode stats: %v", err) - return m, nil - } - streakDates, err := m.store.GetDailyStreakDates() - if err != nil { - log.Printf("failed to get daily streak dates: %v", err) - return m, nil - } - weekliesCompleted, err := m.store.GetCompletedWeeklyGauntlets() - if err != nil { - log.Printf("failed to get weekly gauntlet completions: %v", err) - return m, nil - } - now := time.Now() - currentYear, currentWeek := now.ISOWeek() - thisWeekHighestIndex, err := m.store.GetCurrentWeeklyHighestCompletedIndex(currentYear, currentWeek) - if err != nil { - log.Printf("failed to get current weekly progress: %v", err) - return m, nil - } - currentDaily := false - rec, err := m.store.GetDailyGame(daily.Name(now)) - if err != nil { - log.Printf("failed to check current daily: %v", err) - } else { - currentDaily = rec != nil - } - - weights := stats.WeightsFromDefinitions(registry.Definitions()) - m.stats.cards = stats.BuildCards(weights, catStats, modeStats) - m.stats.profile = stats.BuildProfileBanner( - catStats, - modeStats, - weights, - streakDates, - currentDaily, - weekliesCompleted, - thisWeekHighestIndex, - ) - - statsWidth, statsHeight := statsViewportSize(m.width, m.height, m.stats.cards) - m.stats.viewport = viewport.New( - viewport.WithWidth(statsWidth), - viewport.WithHeight(statsHeight), - ) - m.stats.viewport.SetContent(ui.RenderStatsCardGrid(m.stats.cards, statsWidth)) - m.state = statsView - return m, nil +func asModel(next tea.Model, cmd tea.Cmd) (model, tea.Cmd) { + return next.(model), cmd } diff --git a/app/update_test.go b/app/update_test.go index 4f8f447..f8ba246 100644 --- a/app/update_test.go +++ b/app/update_test.go @@ -128,9 +128,16 @@ func TestGameViewEscapeReturnsToMainMenu(t *testing.T) { func TestImportAndActivateRecordSuccessFlag(t *testing.T) { unknown := model{state: playMenuView} - if _, ok := unknown.importAndActivateRecord(store.GameRecord{GameType: "NoSuchGameType"}); ok { + nextUnknown, ok := unknown.importAndActivateRecord(store.GameRecord{ + Name: "broken-save", + GameType: "NoSuchGameType", + }) + if ok { t.Fatal("expected unknown game type import to fail") } + if nextUnknown.notice.message == "" { + t.Fatal("expected failed import to surface a user-visible notice") + } loadedGame, err := lightsout.New(3, 3) if err != nil { @@ -170,6 +177,9 @@ func TestImportAndActivateRecordSuccessFlag(t *testing.T) { if next.session.game == nil { t.Fatal("expected game to be activated") } + if next.notice.message != "" { + t.Fatalf("notice = %q, want empty after successful import", next.notice.message) + } } func TestHandleSeedConfirmDoesNotResumeStatusWhenImportFails(t *testing.T) { @@ -183,6 +193,8 @@ func TestHandleSeedConfirmDoesNotResumeStatusWhenImportFails(t *testing.T) { InitialState: "{}", SaveState: "{}", Status: store.StatusAbandoned, + RunKind: store.RunKindSeeded, + SeedText: seed, } if err := s.CreateGame(rec); err != nil { t.Fatal(err) @@ -193,7 +205,7 @@ func TestHandleSeedConfirmDoesNotResumeStatusWhenImportFails(t *testing.T) { m := model{ state: seedInputView, store: s, - nav: navigationState{seedInput: ti}, + seed: seedState{input: ti}, } next, _ := m.handleSeedConfirm() @@ -222,27 +234,25 @@ func TestSeedInputSelectorCyclesAndPersistsDefault(t *testing.T) { m := model{ state: seedInputView, - nav: navigationState{ - seedModeOptions: options, - seedFocus: seedFocusMode, + seed: seedState{ + modeOptions: options, + focus: seedFocusMode, }, } next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyRight}) got := next.(model) - if got.nav.seedModeIndex != 1 { - t.Fatalf("seedModeIndex = %d, want 1", got.nav.seedModeIndex) + if got.seed.modeIndex != 1 { + t.Fatalf("seedModeIndex = %d, want 1", got.seed.modeIndex) } - if got.nav.lastSeedModeKey != options[1].key { - t.Fatalf("lastSeedModeKey = %q, want %q", got.nav.lastSeedModeKey, options[1].key) + if got.seed.lastModeKey != options[1].key { + t.Fatalf("lastSeedModeKey = %q, want %q", got.seed.lastModeKey, options[1].key) } reopenedModel, _ := (model{ state: playMenuView, - nav: navigationState{ - lastSeedModeKey: got.nav.lastSeedModeKey, - }, + seed: seedState{lastModeKey: got.seed.lastModeKey}, }).enterSeedInputView() if reopenedModel.state != seedInputView { @@ -266,10 +276,10 @@ func TestHandleSeedConfirmUsesSelectedSpecificMode(t *testing.T) { m := model{ state: seedInputView, store: s, - nav: navigationState{ - seedInput: ti, - seedModeOptions: options, - seedModeIndex: 1, + seed: seedState{ + input: ti, + modeOptions: options, + modeIndex: 1, }, } @@ -309,6 +319,7 @@ func TestHandleDailyPuzzleDoesNotResumeStatusWhenImportFails(t *testing.T) { continue } seen[name] = true + day := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, d.Location()) rec := &store.GameRecord{ Name: name, GameType: "NoSuchGameType", @@ -316,6 +327,8 @@ func TestHandleDailyPuzzleDoesNotResumeStatusWhenImportFails(t *testing.T) { InitialState: "{}", SaveState: "{}", Status: store.StatusAbandoned, + RunKind: store.RunKindDaily, + RunDate: &day, } if err := s.CreateGame(rec); err != nil { t.Fatal(err) @@ -465,6 +478,10 @@ func TestEnterOnSolvedLatestWeeklyCompletesAndQueuesNext(t *testing.T) { InitialState: "{}", SaveState: "{}", Status: store.StatusInProgress, + RunKind: store.RunKindWeekly, + WeekYear: year, + WeekNumber: weekNumber, + WeekIndex: 1, } if err := s.CreateGame(rec); err != nil { t.Fatal(err) @@ -481,8 +498,8 @@ func TestEnterOnSolvedLatestWeeklyCompletesAndQueuesNext(t *testing.T) { weeklyAdvance: &weekly.Info{Year: year, Week: weekNumber, Index: 1}, completionSaved: false, }, - nav: navigationState{ - weeklyCursor: weekly.StartOfWeek(year, weekNumber, time.Local), + weekly: weeklyState{ + cursor: weekly.StartOfWeek(year, weekNumber, time.Local), }, } @@ -518,14 +535,14 @@ func TestMoveWeeklyWeekDoesNotAdvancePastCurrentWeek(t *testing.T) { m := model{ store: s, - nav: navigationState{ - weeklyCursor: currentCursor, + weekly: weeklyState{ + cursor: currentCursor, }, } got := m.moveWeeklyWeek(1) - if !got.nav.weeklyCursor.Equal(currentCursor) { - t.Fatalf("weeklyCursor = %v, want %v", got.nav.weeklyCursor, currentCursor) + if !got.weekly.cursor.Equal(currentCursor) { + t.Fatalf("weeklyCursor = %v, want %v", got.weekly.cursor, currentCursor) } } @@ -541,6 +558,10 @@ func TestCurrentWeeklyMenuIndexTracksNextPlayableSlot(t *testing.T) { InitialState: "{}", SaveState: "{}", Status: store.StatusCompleted, + RunKind: store.RunKindWeekly, + WeekYear: year, + WeekNumber: weekNumber, + WeekIndex: index, } if err := s.CreateGame(rec); err != nil { t.Fatal(err) diff --git a/app/view.go b/app/view.go index 98a40f3..33233fd 100644 --- a/app/view.go +++ b/app/view.go @@ -13,114 +13,7 @@ import ( ) func (m model) View() tea.View { - var content string - - switch m.state { - case mainMenuView: - content = ui.CenterView(m.width, m.height, m.nav.mainMenu.View()) - case playMenuView: - content = ui.CenterView(m.width, m.height, m.nav.playMenu.ViewAsPanel("Play")) - case optionsMenuView: - items := m.nav.optionsMenu.RenderItems() + "\n\n" + ui.DimItemStyle().Render("- Dami") - panel := ui.Panel("Options", items, "↑/↓ navigate • enter select • esc back") - content = ui.CenterView(m.width, m.height, panel) - case seedInputView: - panel := ui.Panel( - "Enter Seed", - m.seedInputBody(), - "↑/↓ change field • ←/→ game • enter confirm • esc back", - ) - content = ui.CenterView(m.width, m.height, panel) - case gameSelectView: - content = m.gameSelectViewContent() - case modeSelectView: - panel := ui.Panel( - m.nav.selectedCategory.Definition.Name+" — Select Mode", - m.nav.modeSelectList.View(), - "↑/↓ navigate • enter select • esc back", - ) - content = ui.CenterView(m.width, m.height, panel) - case generatingView: - s := m.spinner.View() + " Generating puzzle..." - box := ui.GeneratingFrame().Render(s) - content = ui.CenterView(m.width, m.height, box) - case continueView: - var s string - if len(m.nav.continueGames) == 0 { - s = ui.Panel( - "Saved Games", - "No saved games yet.", - "esc back", - ) - } else { - footer := "↑/↓ navigate • enter resume • esc back" - if pg := ui.TablePagination(m.nav.continueTable); pg != "" { - footer = pg + " " + footer - } - s = ui.Panel( - "Saved Games", - m.nav.continueTable.View(), - footer, - ) - } - content = ui.CenterView(m.width, m.height, s) - case weeklyView: - content = ui.CenterView(m.width, m.height, m.weeklyViewContent()) - case gameView: - if m.session.game == nil { - content = "" - } else { - gameView := lipgloss.NewStyle().MaxWidth(m.width).Render(m.session.game.View()) - centered := gameView - if m.debug.enabled { - debugInfo := lipgloss.NewStyle().MaxWidth(m.width).Render( - ui.DebugStyle().Render(m.debug.info), - ) - centered = lipgloss.JoinVertical(lipgloss.Center, gameView, debugInfo) - } - content = ui.CenterView(m.width, m.height, centered) - } - case helpSelectView: - panel := ui.Panel( - "How to Play", - m.nav.helpSelectList.View(), - "↑/↓ navigate • enter select • esc back", - ) - content = ui.CenterView(m.width, m.height, panel) - case helpDetailView: - panel := ui.Panel( - m.help.category.Definition.Name+" — Guide", - m.help.viewport.View(), - "↑/↓ scroll • esc back", - ) - content = ui.CenterView(m.width, m.height, panel) - case themeSelectView: - content = m.themeSelectViewContent() - case statsView: - statsWidth, _ := statsViewportSize(m.width, m.height, m.stats.cards) - var statsBody string - if len(m.stats.cards) == 0 { - statsBody = m.stats.viewport.View() - } else { - banner := ui.RenderStatsBanner(m.stats.profile, statsWidth) - statsBody = lipgloss.JoinVertical(lipgloss.Left, - banner, - "", - m.stats.viewport.View(), - ) - } - statsBody = lipgloss.NewStyle().Width(statsWidth).Render(statsBody) - panel := ui.Panel( - "Stats", - statsBody, - "↑/↓ scroll • esc back", - ) - content = ui.CenterView(m.width, m.height, panel) - default: - content = fmt.Sprintf("unknown state: %d", m.state) - } - - v := tea.NewView(content) + v := tea.NewView(m.viewContent()) v.AltScreen = true if m.state == gameView { v.MouseMode = tea.MouseModeCellMotion @@ -129,6 +22,18 @@ func (m model) View() tea.View { return v } +func (m model) viewContent() string { + if m.state == gameView { + return m.renderGameView() + } + + screen := m.activeScreen() + if screen == nil { + return fmt.Sprintf("unknown state: %d", m.state) + } + return screen.View(m.notice) +} + func (m model) gameSelectViewContent() string { metrics := categoryPickerSize(m.width, m.height) @@ -156,7 +61,7 @@ func (m model) gameSelectViewContent() string { panel := ui.Panel( "Select Category", - body, + m.appendNotice(body), "↑/↓ navigate • / filter • enter select • esc back", ) return ui.CenterView(m.width, m.height, panel) @@ -164,7 +69,7 @@ func (m model) gameSelectViewContent() string { func (m model) weeklyViewContent() string { title := "Weekly Gauntlet — " + m.weeklyPanelTitle() - if len(m.nav.weeklyRows) == 0 { + if len(m.weekly.rows) == 0 { body := "No completed puzzles for this week yet." if m.isCurrentWeeklySelection() { body = "No weekly puzzles are available." @@ -180,11 +85,11 @@ func (m model) weeklyViewContent() string { if !m.isCurrentWeeklySelection() { footer = "←/→ week • enter review • esc back" } - if pg := ui.TablePagination(m.nav.weeklyTable); pg != "" { + if pg := ui.TablePagination(m.weekly.table); pg != "" { footer = pg + " " + footer } - description := m.nav.weeklyTable.View() + description := m.weekly.table.View() if !m.isCurrentWeeklySelection() { description = lipgloss.JoinVertical( lipgloss.Left, @@ -274,27 +179,20 @@ func renderModeList(entry registry.Entry, width int) string { return strings.Join(lines, "\n") } -// themeSelectViewContent renders the theme picker as a side-by-side layout: -// theme list on the left, color preview panel on the right. func (m model) themeSelectViewContent() string { p := theme.Current() - // Determine selected theme name for the preview. themeName := theme.DefaultThemeName if item, ok := m.theme.list.SelectedItem().(ui.MenuItem); ok { themeName = item.ItemTitle } - // Compute available inner height for the panel content. - // Panel chrome: border (2) + padding (2) + title (1) + blank (1) + footer (1) + blank (1) = 8 const panelChrome = 8 innerH := m.height - panelChrome innerH = max(innerH, 10) - // Left side: theme list. listView := m.theme.list.View() - // Right side: color preview. previewBorder := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(p.Border). @@ -303,13 +201,11 @@ func (m model) themeSelectViewContent() string { preview := theme.PreviewPanel(themeName, innerH-4) previewBox := previewBorder.Render(preview) - // Join side by side with a gap. - spacer := " " - body := lipgloss.JoinHorizontal(lipgloss.Top, listView, spacer, previewBox) + body := lipgloss.JoinHorizontal(lipgloss.Top, listView, " ", previewBox) panel := ui.Panel( "Select Theme", - body, + m.appendNotice(body), "↑/↓ navigate • / filter • enter select • esc back", ) return ui.CenterView(m.width, m.height, panel) diff --git a/app/view_test.go b/app/view_test.go index 82faee7..298f229 100644 --- a/app/view_test.go +++ b/app/view_test.go @@ -1,9 +1,12 @@ package app import ( + "strings" "testing" tea "charm.land/bubbletea/v2" + "github.com/FelineStateMachine/puzzletea/store" + "github.com/FelineStateMachine/puzzletea/ui" ) func TestGameViewRequestsMouseCellMotion(t *testing.T) { @@ -20,3 +23,44 @@ func TestGameViewRequestsMouseCellMotion(t *testing.T) { t.Fatal("expected keyboard event type reporting to remain enabled") } } + +func TestPlayMenuViewIncludesNotice(t *testing.T) { + m := model{ + state: playMenuView, + width: 80, + height: 24, + nav: navigationState{ + playMenu: ui.NewMainMenu(nil), + }, + notice: noticeState{level: noticeLevelError, message: "Could not load puzzle"}, + } + + if got := m.viewContent(); !strings.Contains(got, "Could not load puzzle") { + t.Fatalf("viewContent() missing notice, got %q", got) + } +} + +func TestContinueViewStaysAtNaturalTableWidth(t *testing.T) { + s := openAppTestStore(t) + if err := s.CreateGame(&store.GameRecord{ + Name: "shadow-trail", + GameType: "Sudoku", + Mode: "Medium", + Status: store.StatusInProgress, + RunKind: store.RunKindNormal, + }); err != nil { + t.Fatal(err) + } + table, games := ui.InitContinueTable(s, 30) + m := model{ + state: continueView, + width: 140, + height: 30, + cont: continueState{table: table, games: games}, + } + m = m.resizeActiveScreen() + + if got, want := m.cont.table.Width(), ui.ContinueTableWidth(); got != want { + t.Fatalf("continue table width = %d, want %d", got, want) + } +} diff --git a/app/weekly.go b/app/weekly.go index 7bf79ff..681d0c3 100644 --- a/app/weekly.go +++ b/app/weekly.go @@ -1,7 +1,6 @@ package app import ( - "log" "sort" "strconv" "time" @@ -40,7 +39,7 @@ func (r weeklyRow) tableRow() table.Row { func (m model) enterWeeklyView() (tea.Model, tea.Cmd) { current := weekly.Current(time.Now()) - m.nav.weeklyCursor = weekly.StartOfWeek(current.Year, current.Week, time.Local) + m.weekly.cursor = weekly.StartOfWeek(current.Year, current.Week, time.Local) m = m.refreshWeeklyBrowser() m.state = weeklyView return m, nil @@ -50,21 +49,23 @@ func (m model) refreshWeeklyBrowser() model { year, weekNumber := m.selectedWeek() games, err := m.store.ListWeeklyGames(year, weekNumber) if err != nil { - log.Printf("failed to list weekly games: %v", err) + m = m.setErrorf("Could not load weekly puzzles: %v", err) games = nil + } else { + m = m.clearNotice() } if m.isCurrentWeeklySelection() { - m.nav.weeklyRows = buildCurrentWeeklyRows(year, weekNumber, games) + m.weekly.rows = buildCurrentWeeklyRows(year, weekNumber, games) } else { - m.nav.weeklyRows = buildReviewWeeklyRows(games) + m.weekly.rows = buildReviewWeeklyRows(games) } - rows := make([]table.Row, 0, len(m.nav.weeklyRows)) - for _, row := range m.nav.weeklyRows { + rows := make([]table.Row, 0, len(m.weekly.rows)) + for _, row := range m.weekly.rows { rows = append(rows, row.tableRow()) } - m.nav.weeklyTable = ui.InitWeeklyTable(rows, m.height) + m.weekly.table = ui.InitWeeklyTable(rows, m.height) return m } @@ -75,7 +76,7 @@ func (m model) isCurrentWeeklySelection() bool { } func (m model) selectedWeek() (int, int) { - cursor := m.nav.weeklyCursor + cursor := m.weekly.cursor if cursor.IsZero() { current := weekly.Current(time.Now()) cursor = weekly.StartOfWeek(current.Year, current.Week, time.Local) @@ -89,29 +90,29 @@ func (m model) weeklyPanelTitle() string { } func (m model) moveWeeklyWeek(delta int) model { - if m.nav.weeklyCursor.IsZero() { + if m.weekly.cursor.IsZero() { current := weekly.Current(time.Now()) - m.nav.weeklyCursor = weekly.StartOfWeek(current.Year, current.Week, time.Local) + m.weekly.cursor = weekly.StartOfWeek(current.Year, current.Week, time.Local) } - nextCursor := weekly.AddWeeks(m.nav.weeklyCursor, delta) + nextCursor := weekly.AddWeeks(m.weekly.cursor, delta) current := weekly.Current(time.Now()) currentCursor := weekly.StartOfWeek(current.Year, current.Week, time.Local) if nextCursor.After(currentCursor) { nextCursor = currentCursor } - m.nav.weeklyCursor = nextCursor + m.weekly.cursor = nextCursor return m.refreshWeeklyBrowser() } func (m model) handleWeeklyEnter() (tea.Model, tea.Cmd) { - idx := m.nav.weeklyTable.Cursor() - if idx < 0 || idx >= len(m.nav.weeklyRows) { + idx := m.weekly.table.Cursor() + if idx < 0 || idx >= len(m.weekly.rows) { return m, nil } - return m.openWeeklyRow(m.nav.weeklyRows[idx]) + return m.openWeeklyRow(m.weekly.rows[idx]) } func (m model) openWeeklyRow(row weeklyRow) (tea.Model, tea.Cmd) { @@ -141,7 +142,7 @@ func (m model) openWeeklyRow(row weeklyRow) (tea.Model, tea.Cmd) { m, resumed = m.importAndActivateRecordWithOptions(*row.Record, options) if resumed { if err := sessionflow.ResumeAbandonedDeterministicRecord(m.store, row.Record); err != nil { - log.Printf("%v", err) + m = m.setErrorf("%v", err) } } return m, nil @@ -153,24 +154,22 @@ func (m model) openWeeklyRow(row weeklyRow) (tea.Model, tea.Cmd) { spawner, gameType, modeTitle := weekly.Mode(info.Year, info.Week, info.Index) if spawner == nil { - log.Printf("no weekly mode available for %s", row.Name) - return m, nil + return m.setErrorf("No weekly puzzle is configured for %s", row.Name), nil } rng := weekly.RNG(info.Year, info.Week, info.Index) - ctx, jobID := m.beginSpawnContext() infoCopy := info - m.session.spawn = &spawnRequest{ + cmd := newSessionController(&m).startSeededSpawn(spawner, rng, spawnRequest{ source: spawnSourceWeekly, name: row.Name, gameType: gameType, modeTitle: modeTitle, + run: store.WeeklyRunMetadata(info.Year, info.Week, info.Index), returnState: weeklyView, exitState: weeklyView, weeklyInfo: &infoCopy, - } - m.state = generatingView - return m, tea.Batch(m.spinner.Tick, spawnSeededCmd(spawner, rng, ctx, jobID)) + }) + return m, cmd } func (m model) advanceSolvedWeekly() (model, tea.Cmd, bool) { @@ -180,14 +179,14 @@ func (m model) advanceSolvedWeekly() (model, tea.Cmd, bool) { info := *m.session.weeklyAdvance m = m.persistCompletionIfSolved() - m.nav.weeklyCursor = weekly.StartOfWeek(info.Year, info.Week, time.Local) + m.weekly.cursor = weekly.StartOfWeek(info.Year, info.Week, time.Local) m = m.refreshWeeklyBrowser() m.state = weeklyView - if len(m.nav.weeklyRows) == 0 || !m.nav.weeklyRows[0].Playable { + if len(m.weekly.rows) == 0 || !m.weekly.rows[0].Playable { return m, nil, true } - next, cmd := m.openWeeklyRow(m.nav.weeklyRows[0]) + next, cmd := m.openWeeklyRow(m.weekly.rows[0]) return next.(model), cmd, true } diff --git a/architecture_test.go b/architecture_test.go index 24fc6ad..7254b63 100644 --- a/architecture_test.go +++ b/architecture_test.go @@ -3,7 +3,10 @@ package main_test import ( "go/parser" "go/token" + "os" "path/filepath" + "runtime" + "slices" "strings" "testing" ) @@ -35,25 +38,55 @@ func TestStorePackageDoesNotImportSchedulePackages(t *testing.T) { }) } +func TestGamePackageDoesNotImportPDFExport(t *testing.T) { + assertPackageDoesNotImport(t, "game", []string{ + "github.com/FelineStateMachine/puzzletea/pdfexport", + }) +} + func TestCatalogPackageDoesNotImportConcreteGames(t *testing.T) { - assertPackageDoesNotImport(t, "catalog", []string{ - "github.com/FelineStateMachine/puzzletea/fillomino", - "github.com/FelineStateMachine/puzzletea/hashiwokakero", - "github.com/FelineStateMachine/puzzletea/hitori", - "github.com/FelineStateMachine/puzzletea/lightsout", - "github.com/FelineStateMachine/puzzletea/nonogram", - "github.com/FelineStateMachine/puzzletea/nurikabe", - "github.com/FelineStateMachine/puzzletea/rippleeffect", - "github.com/FelineStateMachine/puzzletea/shikaku", - "github.com/FelineStateMachine/puzzletea/spellpuzzle", - "github.com/FelineStateMachine/puzzletea/sudoku", - "github.com/FelineStateMachine/puzzletea/sudokurgb", - "github.com/FelineStateMachine/puzzletea/takuzu", - "github.com/FelineStateMachine/puzzletea/takuzuplus", - "github.com/FelineStateMachine/puzzletea/wordsearch", + assertPackageDoesNotImport(t, "catalog", concreteGameImportPaths(t)) +} + +func TestBuiltinPrintDoesNotImportConcreteGames(t *testing.T) { + assertPackageDoesNotImport(t, "builtinprint", concreteGameImportPaths(t)) +} + +func TestSessionPackageDoesNotUseNameDerivedRunMetadata(t *testing.T) { + assertFilesDoNotContain(t, "session", []string{ + "RunKindForName(", + "RunDateForName(", + "SeedTextForName(", + "WeeklyIdentityForName(", }) } +func TestStoreCreateGameDoesNotUseNameDerivedRunMetadata(t *testing.T) { + assertFileDoesNotContain(t, filepath.Join("store", "store.go"), []string{ + "RunKindForName(", + "RunDateForName(", + "SeedTextForName(", + "WeeklyIdentityForName(", + }) +} + +func TestPDFExportPackageDoesNotContainMarkdownParseEntrypoints(t *testing.T) { + assertFilesDoNotContain(t, "pdfexport", []string{ + "func ParseMarkdown(", + "func ParseFile(", + "func ParseFiles(", + "func lookupMarkdownBodyParser(", + }) +} + +func TestGamePackageDoesNotExposeLegacyPrintFacade(t *testing.T) { + if _, err := os.Stat(filepath.Join("game", "print_adapter.go")); err == nil { + t.Fatal("game/print_adapter.go should not exist") + } else if !os.IsNotExist(err) { + t.Fatalf("stat game/print_adapter.go: %v", err) + } +} + func assertPackageDoesNotImport(t *testing.T, dir string, forbidden []string) { t.Helper() @@ -81,3 +114,72 @@ func assertPackageDoesNotImport(t *testing.T, dir string, forbidden []string) { } } } + +func concreteGameImportPaths(t testing.TB) []string { + t.Helper() + + pattern := filepath.Join(repoRoot(t), "*", "gamemode.go") + matches, err := filepath.Glob(pattern) + if err != nil { + t.Fatalf("glob concrete game packages: %v", err) + } + + importPaths := make([]string, 0, len(matches)) + for _, match := range matches { + dir := filepath.Base(filepath.Dir(match)) + importPaths = append(importPaths, "github.com/FelineStateMachine/puzzletea/"+dir) + } + slices.Sort(importPaths) + return importPaths +} + +func repoRoot(t testing.TB) string { + t.Helper() + + _, path, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + return filepath.Clean(filepath.Dir(path)) +} + +func assertFileDoesNotContain(t *testing.T, path string, forbidden []string) { + t.Helper() + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + content := string(data) + for _, pattern := range forbidden { + if strings.Contains(content, pattern) { + t.Fatalf("%s contains forbidden pattern %q", path, pattern) + } + } +} + +func assertFilesDoNotContain(t *testing.T, dir string, forbidden []string) { + t.Helper() + + files, err := filepath.Glob(filepath.Join(dir, "*.go")) + if err != nil { + t.Fatalf("glob %s: %v", dir, err) + } + + for _, path := range files { + if strings.HasSuffix(path, "_test.go") { + continue + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + content := string(data) + for _, pattern := range forbidden { + if strings.Contains(content, pattern) { + t.Fatalf("%s contains forbidden pattern %q", path, pattern) + } + } + } +} diff --git a/builtinprint/register.go b/builtinprint/register.go new file mode 100644 index 0000000..12d4ea9 --- /dev/null +++ b/builtinprint/register.go @@ -0,0 +1,23 @@ +// Package builtinprint registers the built-in print adapters exposed by the +// game registry into the export pipeline. +package builtinprint + +import ( + "sync" + + "github.com/FelineStateMachine/puzzletea/pdfexport" + "github.com/FelineStateMachine/puzzletea/registry" +) + +var registerOnce sync.Once + +func Register() { + registerOnce.Do(func() { + for _, entry := range registry.Entries() { + if entry.Print == nil { + continue + } + pdfexport.RegisterPrintAdapter(entry.Print) + } + }) +} diff --git a/builtinprint/register_test.go b/builtinprint/register_test.go new file mode 100644 index 0000000..acfcb2f --- /dev/null +++ b/builtinprint/register_test.go @@ -0,0 +1,22 @@ +package builtinprint + +import ( + "testing" + + "github.com/FelineStateMachine/puzzletea/pdfexport" + "github.com/FelineStateMachine/puzzletea/registry" +) + +func TestRegisterBuiltins(t *testing.T) { + Register() + + for _, entry := range registry.Entries() { + hasAdapter := pdfexport.HasPrintAdapter(entry.Definition.Name) + if entry.Print == nil && hasAdapter { + t.Fatalf("unexpected print adapter for %q", entry.Definition.Name) + } + if entry.Print != nil && !hasAdapter { + t.Fatalf("expected print adapter for %q", entry.Definition.Name) + } + } +} diff --git a/catalog/catalog.go b/catalog/catalog.go index d3b44bc..024f279 100644 --- a/catalog/catalog.go +++ b/catalog/catalog.go @@ -1,3 +1,5 @@ +// Package catalog indexes puzzle definitions, aliases, and daily metadata +// without importing concrete game implementations. package catalog import ( diff --git a/cmd/config_test.go b/cmd/config_test.go index 1343f6a..bc42501 100644 --- a/cmd/config_test.go +++ b/cmd/config_test.go @@ -82,6 +82,7 @@ func TestListCmdUsesActiveConfig(t *testing.T) { InitialState: "{}", SaveState: "{}", Status: store.StatusNew, + RunKind: store.RunKindNormal, }); err != nil { t.Fatal(err) } diff --git a/cmd/export_pdf.go b/cmd/export_pdf.go index b9ae40d..17fa8bd 100644 --- a/cmd/export_pdf.go +++ b/cmd/export_pdf.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/FelineStateMachine/puzzletea/builtinprint" "github.com/FelineStateMachine/puzzletea/pdfexport" "github.com/FelineStateMachine/puzzletea/puzzle" "github.com/FelineStateMachine/puzzletea/registry" @@ -43,6 +44,8 @@ func init() { } func runExportPDF(cmd *cobra.Command, args []string) error { + builtinprint.Register() + docs, err := pdfexport.ParseJSONLFiles(args) if err != nil { return err @@ -201,10 +204,7 @@ func annotateDifficulty(puzzles []pdfexport.Puzzle, lookup map[string]map[string } func normalizeDifficultyToken(s string) string { - s = strings.ToLower(strings.TrimSpace(s)) - s = strings.ReplaceAll(s, "-", " ") - s = strings.ReplaceAll(s, "_", " ") - return strings.Join(strings.Fields(s), " ") + return puzzle.NormalizeName(s) } // parseCoverColor parses a cover color string in hex ("#RRGGBB") or diff --git a/cmd/export_pdf_test.go b/cmd/export_pdf_test.go index b47999f..e649305 100644 --- a/cmd/export_pdf_test.go +++ b/cmd/export_pdf_test.go @@ -137,7 +137,7 @@ func TestBuildRenderConfigForPDFCoverColorControlsCoverPages(t *testing.T) { } } -func TestRunExportPDFSilentlyNoOpsWhenAllPuzzlesUnsupported(t *testing.T) { +func TestRunExportPDFRejectsInputsWhenAllPuzzlesUnsupported(t *testing.T) { reset := snapshotExportPDFFlags() defer reset() @@ -177,8 +177,12 @@ func TestRunExportPDFSilentlyNoOpsWhenAllPuzzlesUnsupported(t *testing.T) { cmd := &cobra.Command{} cmd.SetOut(&out) - if err := runExportPDF(cmd, []string{input}); err != nil { - t.Fatalf("expected no-op success, got %v", err) + err = runExportPDF(cmd, []string{input}) + if err == nil { + t.Fatal("expected unsupported export error") + } + if !strings.Contains(err.Error(), "no printable puzzles found") { + t.Fatalf("unexpected error: %v", err) } if out.String() != "" { t.Fatalf("expected no stdout output, got %q", out.String()) diff --git a/cmd/new.go b/cmd/new.go index a270fcf..ae83026 100644 --- a/cmd/new.go +++ b/cmd/new.go @@ -87,7 +87,7 @@ func launchNewGame(gameArg, modeArg, seed string, cfg *config.Config) error { name := sessionflow.GenerateUniqueName(s) g = g.SetTitle(name) - rec, err := sessionflow.CreateRecord(s, g, name, entry.Definition.Name, modeTitle) + rec, err := sessionflow.CreateRecord(s, g, name, entry.Definition.Name, modeTitle, store.NormalRunMetadata()) if err != nil { return err } @@ -155,7 +155,7 @@ func launchSeededGame(seed string, cfg *config.Config) error { } g = g.SetTitle(name) - rec, err := sessionflow.CreateRecord(s, g, name, gameType, modeTitle) + rec, err := sessionflow.CreateRecord(s, g, name, gameType, modeTitle, store.SeededRunMetadata(seed)) if err != nil { return err } diff --git a/cmd/new_export.go b/cmd/new_export.go index 28458d0..f0abbd8 100644 --- a/cmd/new_export.go +++ b/cmd/new_export.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/FelineStateMachine/puzzletea/builtinprint" "github.com/FelineStateMachine/puzzletea/game" "github.com/FelineStateMachine/puzzletea/namegen" "github.com/FelineStateMachine/puzzletea/pdfexport" @@ -27,6 +28,8 @@ type exportModeEntry struct { } func runNewExport(cmd *cobra.Command, args []string) error { + builtinprint.Register() + if err := validateNewExportFlags(cmd, args); err != nil { return err } @@ -36,7 +39,7 @@ func runNewExport(cmd *cobra.Command, args []string) error { return fmt.Errorf("unknown game %q", args[0]) } if !pdfexport.HasPrintAdapter(entry.Definition.Name) { - return nil + return fmt.Errorf("game %q does not support export", entry.Definition.Name) } modeArg := "" diff --git a/cmd/new_export_test.go b/cmd/new_export_test.go index 43fc832..867ffed 100644 --- a/cmd/new_export_test.go +++ b/cmd/new_export_test.go @@ -22,8 +22,11 @@ func TestRunNewExportRejectsUnsupportedGame(t *testing.T) { cmd, _ := newExportTestCmd(t, false) err := runNewExport(cmd, []string{"lights-out"}) - if err != nil { - t.Fatalf("expected silent no-op for unsupported game, got: %v", err) + if err == nil { + t.Fatal("expected unsupported export error") + } + if !strings.Contains(err.Error(), "does not support export") { + t.Fatalf("unexpected error: %v", err) } if _, statErr := os.Stat(output); !os.IsNotExist(statErr) { t.Fatalf("expected no output file, stat err = %v", statErr) diff --git a/daily/daily.go b/daily/daily.go index 1642a0a..06ff746 100644 --- a/daily/daily.go +++ b/daily/daily.go @@ -8,29 +8,16 @@ import ( "github.com/FelineStateMachine/puzzletea/game" "github.com/FelineStateMachine/puzzletea/namegen" "github.com/FelineStateMachine/puzzletea/registry" + "github.com/FelineStateMachine/puzzletea/schedule" ) -// Entry pairs a SeededSpawner with metadata for the eligible daily pool. -type Entry struct { - Spawner game.SeededSpawner - GameType string - Mode string -} +type Entry = schedule.Entry // eligibleModes is the flattened pool built from each package's DailyModes. var eligibleModes = buildEligibleModes() func buildEligibleModes() []Entry { - registryEntries := registry.DailyEntries() - entries := make([]Entry, 0, len(registryEntries)) - for _, entry := range registryEntries { - entries = append(entries, Entry{ - Spawner: entry.Spawner, - GameType: entry.GameType, - Mode: entry.Mode, - }) - } - return entries + return schedule.BuildEligibleModes(registry.DailyEntries()) } // Seed returns a deterministic uint64 seed derived from the date. @@ -73,24 +60,7 @@ func Mode(date time.Time) (game.SeededSpawner, string, string) { } dateStr := date.Format("2006-01-02") - - var best Entry - var bestHash uint64 - found := false - for _, entry := range eligibleModes { - h := fnv.New64a() - h.Write([]byte(dateStr)) - h.Write([]byte{0}) - h.Write([]byte(entry.GameType)) - h.Write([]byte{0}) - h.Write([]byte(entry.Mode)) - score := h.Sum64() - if !found || score > bestHash { - bestHash = score - best = entry - found = true - } - } + best, found := schedule.SelectBySeed(dateStr, eligibleModes) if !found { return nil, "", "" } diff --git a/daily/daily_test.go b/daily/daily_test.go index 8f79265..2ef71f3 100644 --- a/daily/daily_test.go +++ b/daily/daily_test.go @@ -132,55 +132,6 @@ func TestModeDeterministic(t *testing.T) { } } -func TestModeStableOnPoolChange(t *testing.T) { - // Verify the core property of rendezvous hashing: for a given date, - // the selected entry depends only on the (date, gameType, mode) triple, - // not on the total number of entries in the pool. - // - // We test this by recording the mode for a set of dates, then adding - // a synthetic entry to eligibleModes and confirming that dates which - // did NOT select the new entry still return the same mode as before. - dates := make([]time.Time, 30) - for i := range dates { - dates[i] = time.Date(2026, 1, 1+i, 0, 0, 0, 0, time.UTC) - } - - // Record original selections. - type selection struct { - gameType string - mode string - } - original := make([]selection, len(dates)) - for i, d := range dates { - _, gt, m := Mode(d) - original[i] = selection{gt, m} - } - - // Temporarily add a synthetic entry. - synth := Entry{ - Spawner: eligibleModes[0].Spawner, - GameType: "SyntheticGame", - Mode: "SyntheticMode", - } - eligibleModes = append(eligibleModes, synth) - defer func() { - eligibleModes = eligibleModes[:len(eligibleModes)-1] - }() - - for i, d := range dates { - _, gt, m := Mode(d) - if gt == "SyntheticGame" && m == "SyntheticMode" { - // This date was "stolen" by the new entry — expected for some dates. - continue - } - if gt != original[i].gameType || m != original[i].mode { - t.Errorf("date %s: selection changed from (%q,%q) to (%q,%q) after adding unrelated entry", - d.Format("2006-01-02"), - original[i].gameType, original[i].mode, gt, m) - } - } -} - // --- EligibleModes pool (P1) --- func TestEligibleModesNotEmpty(t *testing.T) { diff --git a/fillomino/README.md b/fillomino/README.md index c7b1c1b..7a02335 100644 --- a/fillomino/README.md +++ b/fillomino/README.md @@ -2,6 +2,31 @@ Grow orthogonally connected regions until each region contains exactly the number written in its cells. +![Fillomino gameplay](../vhs/fillomino.gif) + +## How to Play + +Each number belongs to a region whose area must match that number. +Grow and merge regions until every filled cell belongs to a valid orthogonally +connected group. + +1. Every region must be orthogonally connected. +2. A region's area must equal the number written in its cells. +3. Regions with the same number may not touch orthogonally. +4. Given cells are fixed and cannot be changed. + +## Controls + +| Key | Action | +|-----|--------| +| Arrow keys / WASD / hjkl | Move cursor | +| Mouse left-click | Focus a cell | +| `1`-`9` | Place number | +| `Backspace` / `Delete` | Clear cell | +| `Ctrl+R` | Reset puzzle | +| `Ctrl+H` | Toggle full help | +| `Escape` | Return to main menu | + ## Modes | Mode | Size | Notes | @@ -12,7 +37,7 @@ Grow orthogonally connected regions until each region contains exactly the numbe | Hard 10x10 | 10x10 | Longer chains | | Expert 12x12 | 12x12 | Broad region planning | -## CLI +## Quick Start ```bash puzzletea new fillomino diff --git a/fillomino/Export.go b/fillomino/export.go similarity index 100% rename from fillomino/Export.go rename to fillomino/export.go diff --git a/fillomino/Gamemode.go b/fillomino/gamemode.go similarity index 66% rename from fillomino/Gamemode.go rename to fillomino/gamemode.go index b731a5a..954b907 100644 --- a/fillomino/Gamemode.go +++ b/fillomino/gamemode.go @@ -4,8 +4,9 @@ import ( _ "embed" "math/rand/v2" - "charm.land/bubbles/v2/list" "github.com/FelineStateMachine/puzzletea/game" + "github.com/FelineStateMachine/puzzletea/gameentry" + "github.com/FelineStateMachine/puzzletea/puzzle" ) //go:embed help.md @@ -49,7 +50,7 @@ func (m Mode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) { return New(m, puzzle) } -var Modes = []list.Item{ +var Modes = []game.Mode{ NewMode("Mini 5x5", "Small board with generous clues.", 5, 5, 0.70), NewMode("Easy 6x6", "Compact board with simple regions.", 6, 6, 0.66), NewMode("Medium 8x8", "Balanced deduction and region growth.", 8, 7, 0.60), @@ -57,18 +58,20 @@ var Modes = []list.Item{ NewMode("Expert 12x12", "Wide board with long deduction chains.", 12, 9, 0.52), } -var DailyModes = []list.Item{ - Modes[1], - Modes[2], - Modes[3], -} +var ModeDefinitions = gameentry.BuildModeDefs(Modes) -var Definition = game.Definition{ - Name: "Fillomino", - Description: "Grow the numbered regions to their exact sizes.", - Aliases: []string{"polyomino", "regions"}, - Modes: Modes, - DailyModes: DailyModes, - Help: HelpContent, - Import: func(data []byte) (game.Gamer, error) { return ImportModel(data) }, -} +var Definition = puzzle.NewDefinition(puzzle.DefinitionSpec{ + Name: "Fillomino", + Description: "Grow the numbered regions to their exact sizes.", + Aliases: []string{"polyomino", "regions"}, + Modes: ModeDefinitions, + DailyModeIDs: puzzle.SelectModeIDsByIndex(ModeDefinitions, 1, 2, 3), +}) + +var Entry = gameentry.NewEntry(gameentry.EntrySpec{ + Definition: Definition, + Help: HelpContent, + Import: game.AdaptImport(ImportModel), + Modes: Modes, + Print: PDFPrintAdapter, +}) diff --git a/fillomino/help.md b/fillomino/help.md index c5b46d6..1512b87 100644 --- a/fillomino/help.md +++ b/fillomino/help.md @@ -14,9 +14,10 @@ Fill the board so that each orthogonally connected region contains exactly as ma | Key | Action | |-----|--------| | `Arrows` / `wasd` / `hjkl` | Move cursor | +| `Mouse left-click` | Focus a cell | | `1`-`9` | Place number | | `Backspace` / `Delete` | Clear cell | -| `Ctrl+H` | Toggle help bar | +| `Ctrl+H` | Toggle full help | | `Ctrl+R` | Reset puzzle | | `Escape` | Return to main menu | diff --git a/fillomino/Model.go b/fillomino/model.go similarity index 100% rename from fillomino/Model.go rename to fillomino/model.go diff --git a/fillomino/PrintAdapter.go b/fillomino/print_adapter.go similarity index 97% rename from fillomino/PrintAdapter.go rename to fillomino/print_adapter.go index e1049be..c51243b 100644 --- a/fillomino/PrintAdapter.go +++ b/fillomino/print_adapter.go @@ -9,6 +9,8 @@ import ( type printAdapter struct{} +var PDFPrintAdapter = printAdapter{} + func (printAdapter) CanonicalGameType() string { return "Fillomino" } func (printAdapter) Aliases() []string { return []string{"fillomino", "polyomino", "regions"} } @@ -83,7 +85,3 @@ func drawFillominoGiven(pdf *fpdf.Fpdf, x, y, cellSize float64, text string) { pdf.SetXY(x, y+(cellSize-lineH)/2) pdf.CellFormat(cellSize, lineH, text, "", 0, "C", false, 0, "") } - -func init() { - pdfexport.RegisterPrintAdapter(printAdapter{}) -} diff --git a/game/game_test.go b/game/game_test.go index d45010a..38103b6 100644 --- a/game/game_test.go +++ b/game/game_test.go @@ -202,43 +202,6 @@ func TestBaseMode(t *testing.T) { }) } -// --- Category (P3) --- - -func TestCategory(t *testing.T) { - c := Category{Name: "Sudoku", Desc: "Classic"} - - if got := c.Title(); got != "Sudoku" { - t.Errorf("Title() = %q, want %q", got, "Sudoku") - } - if got := c.Description(); got != "Classic" { - t.Errorf("Description() = %q, want %q", got, "Classic") - } - if got := c.FilterValue(); got != "Sudoku" { - t.Errorf("FilterValue() = %q, want %q", got, "Sudoku") - } -} - -// --- Definition (P1) --- - -func TestDefinitionCategory(t *testing.T) { - def := Definition{ - Name: "Sudoku", - Description: "Classic grid logic", - Help: "Rules", - } - - cat := def.Category() - if cat.Name != def.Name { - t.Fatalf("Category().Name = %q, want %q", cat.Name, def.Name) - } - if cat.Desc != def.Description { - t.Fatalf("Category().Desc = %q, want %q", cat.Desc, def.Description) - } - if cat.Help != def.Help { - t.Fatalf("Category().Help = %q, want %q", cat.Help, def.Help) - } -} - func TestNormalizeName(t *testing.T) { tests := []struct { input string diff --git a/game/gamer.go b/game/gamer.go index f19448e..ec99099 100644 --- a/game/gamer.go +++ b/game/gamer.go @@ -4,11 +4,10 @@ package game import ( "context" "math/rand/v2" - "strings" "charm.land/bubbles/v2/key" - "charm.land/bubbles/v2/list" tea "charm.land/bubbletea/v2" + "github.com/FelineStateMachine/puzzletea/puzzle" ) // Gamer is the interface that an active game instance must implement. @@ -76,45 +75,16 @@ func (b BaseMode) Title() string { return b.title } func (b BaseMode) Description() string { return b.description } func (b BaseMode) FilterValue() string { return b.title + " " + b.description } -// Category groups related game modes under a heading in the menu. -type Category struct { - Name string - Desc string - Modes []list.Item - Help string // embedded help.md content rendered in "How to Play" -} - -func (c Category) Title() string { return c.Name } -func (c Category) Description() string { return c.Desc } -func (c Category) FilterValue() string { return c.Name } - -// Definition is the package-level metadata for a puzzle game. -type Definition struct { - Name string - Description string - Aliases []string - Modes []list.Item - DailyModes []list.Item - Help string - Import func([]byte) (Gamer, error) -} - -func (d Definition) Category() Category { - return Category{ - Name: d.Name, - Desc: d.Description, - Modes: d.Modes, - Help: d.Help, +func AdaptImport[T Gamer](fn func([]byte) (T, error)) func([]byte) (Gamer, error) { + return func(data []byte) (Gamer, error) { + return fn(data) } } // NormalizeName lowercases and replaces hyphens/underscores with spaces for // fuzzy matching of CLI arguments to game names and aliases. func NormalizeName(s string) string { - s = strings.ToLower(s) - s = strings.ReplaceAll(s, "-", " ") - s = strings.ReplaceAll(s, "_", " ") - return strings.TrimSpace(s) + return puzzle.NormalizeName(s) } // SpawnCompleteMsg is sent when an async Spawn() call finishes. diff --git a/game/print_adapter.go b/game/print_adapter.go deleted file mode 100644 index b73e1b2..0000000 --- a/game/print_adapter.go +++ /dev/null @@ -1,21 +0,0 @@ -package game - -import "github.com/FelineStateMachine/puzzletea/pdfexport" - -type PrintAdapter = pdfexport.PrintAdapter - -func RegisterPrintAdapter(adapter PrintAdapter) { - pdfexport.RegisterPrintAdapter(adapter) -} - -func LookupPrintAdapter(gameType string) (PrintAdapter, bool) { - return pdfexport.LookupPrintAdapter(gameType) -} - -func HasPrintAdapter(gameType string) bool { - return pdfexport.HasPrintAdapter(gameType) -} - -func IsNilPrintPayload(payload any) bool { - return pdfexport.IsNilPrintPayload(payload) -} diff --git a/game/print_adapter_test.go b/game/print_adapter_test.go deleted file mode 100644 index f5bbbc4..0000000 --- a/game/print_adapter_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package game - -import ( - "testing" - - "codeberg.org/go-pdf/fpdf" -) - -type testPrintAdapter struct { - canonical string - aliases []string -} - -func (a testPrintAdapter) CanonicalGameType() string { return a.canonical } -func (a testPrintAdapter) Aliases() []string { return a.aliases } -func (a testPrintAdapter) BuildPDFPayload([]byte) (any, error) { return nil, nil } -func (a testPrintAdapter) RenderPDFBody(*fpdf.Fpdf, any) error { return nil } - -func TestPrintAdapterRegistryLookupAndAliases(t *testing.T) { - adapter := testPrintAdapter{ - canonical: "Test Word Search", - aliases: []string{"testwordsearch", "test-word-search"}, - } - RegisterPrintAdapter(adapter) - - if !HasPrintAdapter("Test Word Search") { - t.Fatal("expected canonical lookup to be supported") - } - if !HasPrintAdapter("test_word_search") { - t.Fatal("expected underscore alias lookup to be supported") - } - if !HasPrintAdapter("testwordsearch") { - t.Fatal("expected compact alias lookup to be supported") - } - if HasPrintAdapter("lights out") { - t.Fatal("expected unknown type to be unsupported") - } -} - -func TestRegisterPrintAdapterSkipsBlankCanonical(t *testing.T) { - RegisterPrintAdapter(testPrintAdapter{canonical: " "}) - if HasPrintAdapter("should-not-exist") { - t.Fatal("blank canonical adapter should not create lookups") - } -} - -func TestIsNilPrintPayload(t *testing.T) { - var ptr *int - if !IsNilPrintPayload(ptr) { - t.Fatal("expected typed nil pointer to be treated as nil payload") - } - if IsNilPrintPayload(5) { - t.Fatal("expected concrete payload to be non-nil") - } -} diff --git a/gameentry/gameentry.go b/gameentry/gameentry.go new file mode 100644 index 0000000..85a6ca1 --- /dev/null +++ b/gameentry/gameentry.go @@ -0,0 +1,105 @@ +// Package gameentry builds concrete runtime game entries from puzzle definitions +// and runtime mode implementations. +package gameentry + +import ( + "fmt" + + "github.com/FelineStateMachine/puzzletea/game" + "github.com/FelineStateMachine/puzzletea/pdfexport" + "github.com/FelineStateMachine/puzzletea/puzzle" +) + +type ModeEntry struct { + Definition puzzle.ModeDef + Spawner game.Spawner + Seeded game.SeededSpawner +} + +type Entry struct { + Definition puzzle.Definition + Help string + Import func([]byte) (game.Gamer, error) + Modes []ModeEntry + Print pdfexport.PrintAdapter +} + +type EntrySpec struct { + Definition puzzle.Definition + Help string + Import func([]byte) (game.Gamer, error) + Modes []game.Mode + Print pdfexport.PrintAdapter +} + +func BuildModeDefs(modes []game.Mode) []puzzle.ModeDef { + defs := make([]puzzle.ModeDef, 0, len(modes)) + for _, mode := range modes { + def := puzzle.NewModeDef(puzzle.ModeSpec{ + Title: mode.Title(), + Description: mode.Description(), + }) + if _, ok := mode.(game.SeededSpawner); ok { + def.Seeded = true + } + defs = append(defs, def) + } + return defs +} + +func NewEntry(spec EntrySpec) Entry { + if len(spec.Definition.Modes) != len(spec.Modes) { + panic(fmt.Sprintf( + "gameentry: definition %q has %d mode definitions but %d runtime modes", + spec.Definition.Name, + len(spec.Definition.Modes), + len(spec.Modes), + )) + } + + entries := make([]ModeEntry, 0, len(spec.Modes)) + for i, mode := range spec.Modes { + spawner, ok := mode.(game.Spawner) + if !ok { + panic(fmt.Sprintf( + "gameentry: definition %q mode %q does not implement game.Spawner", + spec.Definition.Name, + mode.Title(), + )) + } + + modeDef := spec.Definition.Modes[i] + if modeDef.Title != mode.Title() || modeDef.Description != mode.Description() { + panic(fmt.Sprintf( + "gameentry: definition %q mode %d metadata does not match runtime mode %q", + spec.Definition.Name, + i, + mode.Title(), + )) + } + + entry := ModeEntry{ + Definition: modeDef, + Spawner: spawner, + } + if seeded, ok := mode.(game.SeededSpawner); ok { + entry.Seeded = seeded + } + if entry.Definition.Seeded != (entry.Seeded != nil) { + panic(fmt.Sprintf( + "gameentry: definition %q mode %q seeded flag does not match runtime mode", + spec.Definition.Name, + modeDef.Title, + )) + } + entries = append(entries, entry) + } + + return Entry{ + Definition: spec.Definition, + Help: spec.Help, + Import: spec.Import, + Modes: entries, + Print: spec.Print, + } +} diff --git a/gameentry/gameentry_test.go b/gameentry/gameentry_test.go new file mode 100644 index 0000000..05b337e --- /dev/null +++ b/gameentry/gameentry_test.go @@ -0,0 +1,75 @@ +package gameentry + +import ( + "errors" + "math/rand/v2" + "testing" + + "github.com/FelineStateMachine/puzzletea/game" + "github.com/FelineStateMachine/puzzletea/puzzle" +) + +type stubMode struct { + game.BaseMode +} + +func (m stubMode) Spawn() (game.Gamer, error) { + return nil, errors.New("not implemented") +} + +type seededStubMode struct { + stubMode +} + +func (m seededStubMode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) { + return nil, errors.New("not implemented") +} + +func TestBuildModeDefsMarksSeededModes(t *testing.T) { + modes := []game.Mode{ + stubMode{BaseMode: game.NewBaseMode("Beginner", "Easy board")}, + seededStubMode{stubMode{BaseMode: game.NewBaseMode("Expert", "Hard board")}}, + } + + defs := BuildModeDefs(modes) + if len(defs) != 2 { + t.Fatalf("len(BuildModeDefs) = %d, want 2", len(defs)) + } + if defs[0].Seeded { + t.Fatal("defs[0].Seeded = true, want false") + } + if !defs[1].Seeded { + t.Fatal("defs[1].Seeded = false, want true") + } +} + +func TestNewEntryKeepsMetadataAndRuntimeModesAligned(t *testing.T) { + modes := []game.Mode{ + seededStubMode{stubMode{BaseMode: game.NewBaseMode("Medium", "Balanced board")}}, + } + definition := puzzle.NewDefinition(puzzle.DefinitionSpec{ + Name: "Sudoku", + Modes: BuildModeDefs(modes), + DailyModeIDs: []puzzle.ModeID{"medium"}, + }) + + entry := NewEntry(EntrySpec{ + Definition: definition, + Help: "Rules", + Import: func([]byte) (game.Gamer, error) { return nil, nil }, + Modes: modes, + }) + + if got, want := entry.Definition.Name, "Sudoku"; got != want { + t.Fatalf("Definition.Name = %q, want %q", got, want) + } + if got, want := len(entry.Modes), 1; got != want { + t.Fatalf("len(Modes) = %d, want %d", got, want) + } + if entry.Modes[0].Seeded == nil { + t.Fatal("Modes[0].Seeded = nil, want seeded spawner") + } + if entry.Print != nil { + t.Fatal("Print = non-nil, want nil") + } +} diff --git a/hashiwokakero/README.md b/hashiwokakero/README.md index 58b27ea..6b25ccf 100644 --- a/hashiwokakero/README.md +++ b/hashiwokakero/README.md @@ -25,10 +25,10 @@ bridges and all islands are connected. | Key | Action | |-----|--------| | Arrow keys / WASD / hjkl | Jump to nearest island | +| Mouse left-click | Focus a clicked island | | `Enter` / `Space` | Select island for bridging | | `Ctrl+R` | Reset puzzle | | `Ctrl+H` | Toggle full help | -| `Ctrl+E` | Toggle debug overlay | | `Escape` | Return to main menu | ### Bridge Mode (after selecting an island) diff --git a/hashiwokakero/Export.go b/hashiwokakero/export.go similarity index 100% rename from hashiwokakero/Export.go rename to hashiwokakero/export.go diff --git a/hashiwokakero/Gamemode.go b/hashiwokakero/gamemode.go similarity index 73% rename from hashiwokakero/Gamemode.go rename to hashiwokakero/gamemode.go index db33fd6..f23cec3 100644 --- a/hashiwokakero/Gamemode.go +++ b/hashiwokakero/gamemode.go @@ -4,8 +4,9 @@ import ( _ "embed" "math/rand/v2" - "charm.land/bubbles/v2/list" "github.com/FelineStateMachine/puzzletea/game" + "github.com/FelineStateMachine/puzzletea/gameentry" + "github.com/FelineStateMachine/puzzletea/puzzle" ) //go:embed help.md @@ -51,7 +52,7 @@ func (h HashiMode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) { return New(h, puzzle), nil } -var Modes = []list.Item{ +var Modes = []game.Mode{ NewMode("Easy 7x7", "7x7 grid with 8-10 islands.", 7, 7, 8, 10), NewMode("Medium 7x7", "7x7 grid with 12-15 islands.", 7, 7, 12, 15), NewMode("Hard 7x7", "7x7 grid with 17-20 islands.", 7, 7, 17, 20), @@ -66,17 +67,20 @@ var Modes = []list.Item{ NewMode("Hard 13x13", "13x13 grid with 59-68 islands.", 13, 13, 59, 68), } -var DailyModes = []list.Item{ - Modes[3], // Easy 9x9 - Modes[1], // Medium 7x7 -} +var ModeDefinitions = gameentry.BuildModeDefs(Modes) -var Definition = game.Definition{ - Name: "Hashiwokakero", - Description: "Connect the islands with bridges.", - Aliases: []string{"hashi", "bridges"}, - Modes: Modes, - DailyModes: DailyModes, - Help: HelpContent, - Import: func(data []byte) (game.Gamer, error) { return ImportModel(data) }, -} +var Definition = puzzle.NewDefinition(puzzle.DefinitionSpec{ + Name: "Hashiwokakero", + Description: "Connect the islands with bridges.", + Aliases: []string{"hashi", "bridges"}, + Modes: ModeDefinitions, + DailyModeIDs: puzzle.SelectModeIDsByIndex(ModeDefinitions, 3, 1), +}) + +var Entry = gameentry.NewEntry(gameentry.EntrySpec{ + Definition: Definition, + Help: HelpContent, + Import: game.AdaptImport(ImportModel), + Modes: Modes, + Print: PDFPrintAdapter, +}) diff --git a/hashiwokakero/help.md b/hashiwokakero/help.md index e842b0c..9418aed 100644 --- a/hashiwokakero/help.md +++ b/hashiwokakero/help.md @@ -16,6 +16,7 @@ Connect numbered islands with bridges to form a single connected group. | Key | Action | |-----|--------| | `Arrows` / `wasd` / `hjkl` | Jump to nearest island | +| `Mouse left-click` | Focus a clicked island | | `Enter` / `Space` | Select island | ### Bridge Mode @@ -35,7 +36,7 @@ double bridge, and a third time removes it. | Key | Action | |-----|--------| | `Ctrl+R` | Reset puzzle | -| `Ctrl+H` | Toggle help bar | +| `Ctrl+H` | Toggle full help | | `Escape` | Return to main menu | ## Tips diff --git a/hashiwokakero/Model.go b/hashiwokakero/model.go similarity index 100% rename from hashiwokakero/Model.go rename to hashiwokakero/model.go diff --git a/hashiwokakero/PrintAdapter.go b/hashiwokakero/print_adapter.go similarity index 98% rename from hashiwokakero/PrintAdapter.go rename to hashiwokakero/print_adapter.go index abda080..6dff097 100644 --- a/hashiwokakero/PrintAdapter.go +++ b/hashiwokakero/print_adapter.go @@ -11,6 +11,8 @@ import ( type printAdapter struct{} +var PDFPrintAdapter = printAdapter{} + func (printAdapter) CanonicalGameType() string { return "Hashiwokakero" } func (printAdapter) Aliases() []string { return []string{"hashi", "hashiwokakero", "hashi wokakero"} @@ -133,7 +135,3 @@ func drawHashiIslandNumber(pdf *fpdf.Fpdf, cx, cy, radius float64, required int) pdf.SetXY(cx-radius, cy-lineH/2) pdf.CellFormat(radius*2, lineH, text, "", 0, "C", false, 0, "") } - -func init() { - pdfexport.RegisterPrintAdapter(printAdapter{}) -} diff --git a/hitori/README.md b/hitori/README.md index 240de68..427a36e 100644 --- a/hitori/README.md +++ b/hitori/README.md @@ -20,11 +20,11 @@ The puzzle is solved when all three rules hold simultaneously. | Key | Action | |-----|--------| | Arrow keys / WASD / hjkl | Move cursor | +| Mouse left-click | Focus a cell | | `x` | Shade cell (toggle) | | `z` | Circle cell as confirmed white (toggle) | | `Backspace` | Clear cell to unmarked | | `Ctrl+H` | Toggle full help | -| `Ctrl+E` | Toggle debug overlay | | `Ctrl+R` | Reset puzzle | | `Escape` | Return to main menu | diff --git a/hitori/Export.go b/hitori/export.go similarity index 100% rename from hitori/Export.go rename to hitori/export.go diff --git a/hitori/Gamemode.go b/hitori/gamemode.go similarity index 67% rename from hitori/Gamemode.go rename to hitori/gamemode.go index 86690d0..c2d3ae2 100644 --- a/hitori/Gamemode.go +++ b/hitori/gamemode.go @@ -4,8 +4,9 @@ import ( _ "embed" "math/rand/v2" - "charm.land/bubbles/v2/list" "github.com/FelineStateMachine/puzzletea/game" + "github.com/FelineStateMachine/puzzletea/gameentry" + "github.com/FelineStateMachine/puzzletea/puzzle" ) //go:embed help.md @@ -47,7 +48,7 @@ func (h HitoriMode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) { return New(h, puzzle) } -var Modes = []list.Item{ +var Modes = []game.Mode{ NewMode("Mini", "5\u00d75 grid, gentle introduction.", 5, 0.32), NewMode("Easy", "6\u00d76 grid, straightforward logic.", 6, 0.32), NewMode("Medium", "8\u00d78 grid, moderate challenge.", 8, 0.30), @@ -56,16 +57,19 @@ var Modes = []list.Item{ NewMode("Expert", "12\u00d712 grid, maximum challenge.", 12, 0.28), } -var DailyModes = []list.Item{ - Modes[1], // Easy 6x6 - Modes[2], // Medium 8x8 -} +var ModeDefinitions = gameentry.BuildModeDefs(Modes) -var Definition = game.Definition{ - Name: "Hitori", - Description: "Shade the cells to eliminate duplicates.", - Modes: Modes, - DailyModes: DailyModes, - Help: HelpContent, - Import: func(data []byte) (game.Gamer, error) { return ImportModel(data) }, -} +var Definition = puzzle.NewDefinition(puzzle.DefinitionSpec{ + Name: "Hitori", + Description: "Shade the cells to eliminate duplicates.", + Modes: ModeDefinitions, + DailyModeIDs: puzzle.SelectModeIDsByIndex(ModeDefinitions, 1, 2), +}) + +var Entry = gameentry.NewEntry(gameentry.EntrySpec{ + Definition: Definition, + Help: HelpContent, + Import: game.AdaptImport(ImportModel), + Modes: Modes, + Print: PDFPrintAdapter, +}) diff --git a/hitori/help.md b/hitori/help.md index ae32546..d7bf733 100644 --- a/hitori/help.md +++ b/hitori/help.md @@ -18,10 +18,11 @@ The puzzle is solved when all three rules hold simultaneously. | Key | Action | |-----|--------| | `Arrows` / `wasd` / `hjkl` | Move cursor | +| `Mouse left-click` | Focus a cell | | `x` | Toggle shade on cell | | `z` | Toggle circle (confirmed white) | | `Backspace` | Clear cell to unmarked | -| `Ctrl+H` | Toggle help bar | +| `Ctrl+H` | Toggle full help | | `Ctrl+R` | Reset puzzle | | `Escape` | Return to main menu | diff --git a/hitori/hitori_test.go b/hitori/hitori_test.go index 17bee51..01832f8 100644 --- a/hitori/hitori_test.go +++ b/hitori/hitori_test.go @@ -1385,7 +1385,7 @@ func TestRegistration(t *testing.T) { g, _ := New(mode, numbers) data, _ := g.GetSave() - loaded, err := Definition.Import(data) + loaded, err := Entry.Import(data) if err != nil { t.Fatalf("definition import: %v", err) } diff --git a/hitori/Model.go b/hitori/model.go similarity index 100% rename from hitori/Model.go rename to hitori/model.go diff --git a/hitori/PrintAdapter.go b/hitori/print_adapter.go similarity index 97% rename from hitori/PrintAdapter.go rename to hitori/print_adapter.go index 697f68f..758168f 100644 --- a/hitori/PrintAdapter.go +++ b/hitori/print_adapter.go @@ -10,6 +10,8 @@ import ( type printAdapter struct{} +var PDFPrintAdapter = printAdapter{} + func (printAdapter) CanonicalGameType() string { return "Hitori" } func (printAdapter) Aliases() []string { return []string{"hitori"} } @@ -99,7 +101,3 @@ func drawHitoriCellNumber(pdf *fpdf.Fpdf, x, y, cellSize float64, text string) { pdf.SetXY(x, y+(cellSize-lineH)/2) pdf.CellFormat(cellSize, lineH, text, "", 0, "C", false, 0, "") } - -func init() { - pdfexport.RegisterPrintAdapter(printAdapter{}) -} diff --git a/lightsout/README.md b/lightsout/README.md index a29c92e..9d7a647 100644 --- a/lightsout/README.md +++ b/lightsout/README.md @@ -15,10 +15,10 @@ goal is to turn every light off. | Key | Action | |-----|--------| | Arrow keys / WASD / hjkl | Move cursor | +| Mouse left-click | Toggle light | | `Enter` / `Space` | Toggle light | | `Ctrl+R` | Reset puzzle | | `Ctrl+H` | Toggle full help | -| `Ctrl+E` | Toggle debug overlay | | `Escape` | Return to main menu | ## Modes diff --git a/lightsout/Export.go b/lightsout/export.go similarity index 100% rename from lightsout/Export.go rename to lightsout/export.go diff --git a/lightsout/Gamemode.go b/lightsout/gamemode.go similarity index 56% rename from lightsout/Gamemode.go rename to lightsout/gamemode.go index f12b34c..f21bbe7 100644 --- a/lightsout/Gamemode.go +++ b/lightsout/gamemode.go @@ -4,8 +4,9 @@ import ( _ "embed" "math/rand/v2" - "charm.land/bubbles/v2/list" "github.com/FelineStateMachine/puzzletea/game" + "github.com/FelineStateMachine/puzzletea/gameentry" + "github.com/FelineStateMachine/puzzletea/puzzle" ) //go:embed help.md @@ -38,24 +39,26 @@ func (m Mode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) { return NewSeeded(m.Width, m.Height, rng) } -var Modes = []list.Item{ +var Modes = []game.Mode{ NewMode("Easy", "3x3 grid", 3, 3), NewMode("Medium", "5x5 grid", 5, 5), NewMode("Hard", "7x7 grid", 7, 7), NewMode("Extreme", "9x9 grid", 9, 9), } -var DailyModes = []list.Item{ - Modes[1], // Medium 5x5 - Modes[2], // Hard 7x7 -} +var ModeDefinitions = gameentry.BuildModeDefs(Modes) -var Definition = game.Definition{ - Name: "Lights Out", - Description: "Turn the lights off.", - Aliases: []string{"lights"}, - Modes: Modes, - DailyModes: DailyModes, - Help: HelpContent, - Import: func(data []byte) (game.Gamer, error) { return ImportModel(data) }, -} +var Definition = puzzle.NewDefinition(puzzle.DefinitionSpec{ + Name: "Lights Out", + Description: "Turn the lights off.", + Aliases: []string{"lights"}, + Modes: ModeDefinitions, + DailyModeIDs: puzzle.SelectModeIDsByIndex(ModeDefinitions, 1, 2), +}) + +var Entry = gameentry.NewEntry(gameentry.EntrySpec{ + Definition: Definition, + Help: HelpContent, + Import: game.AdaptImport(ImportModel), + Modes: Modes, +}) diff --git a/lightsout/help.md b/lightsout/help.md index 870354c..0c1207c 100644 --- a/lightsout/help.md +++ b/lightsout/help.md @@ -18,7 +18,7 @@ Toggle lights in a cross pattern to turn them all off. |-----|--------| | `Arrows` / `wasd` / `hjkl` | Move cursor | | `Enter` / `Space` | Toggle light | -| `Ctrl+H` | Toggle help bar | +| `Ctrl+H` | Toggle full help | | `Ctrl+R` | Reset puzzle | | `Escape` | Return to main menu | diff --git a/lightsout/Model.go b/lightsout/model.go similarity index 100% rename from lightsout/Model.go rename to lightsout/model.go diff --git a/nonogram/README.md b/nonogram/README.md index 941ca00..5d448d2 100644 --- a/nonogram/README.md +++ b/nonogram/README.md @@ -25,7 +25,6 @@ correctly satisfied. | `Backspace` | Clear cell | | `Ctrl+R` | Reset puzzle | | `Ctrl+H` | Toggle full help | -| `Ctrl+E` | Toggle debug overlay | | `Escape` | Return to main menu | ## Modes diff --git a/nonogram/Export.go b/nonogram/export.go similarity index 100% rename from nonogram/Export.go rename to nonogram/export.go diff --git a/nonogram/Gamemode.go b/nonogram/gamemode.go similarity index 74% rename from nonogram/Gamemode.go rename to nonogram/gamemode.go index 04f72a9..62fa774 100644 --- a/nonogram/Gamemode.go +++ b/nonogram/gamemode.go @@ -4,8 +4,9 @@ import ( _ "embed" "math/rand/v2" - "charm.land/bubbles/v2/list" "github.com/FelineStateMachine/puzzletea/game" + "github.com/FelineStateMachine/puzzletea/gameentry" + "github.com/FelineStateMachine/puzzletea/puzzle" ) //go:embed help.md @@ -42,7 +43,7 @@ func (n NonogramMode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) { return New(n, hints) } -var Modes = []list.Item{ +var Modes = []game.Mode{ // 5x5 NewMode("Mini", "5x5 grid, ~65% filled. Quick puzzle, straightforward hints.", 5, 5, 0.65), NewMode("Pocket", "5x5 grid, ~50% filled. Compact but balanced.", 5, 5, 0.50), @@ -59,16 +60,19 @@ var Modes = []list.Item{ NewMode("Massive", "20x20 grid, ~56% filled. Truly massive puzzle.", 20, 20, 0.56), } -var DailyModes = []list.Item{ - Modes[3], // Standard 10x10 - Modes[4], // Classic 10x10 -} +var ModeDefinitions = gameentry.BuildModeDefs(Modes) -var Definition = game.Definition{ - Name: "Nonogram", - Description: "Fill the cells to match tomographic hints.", - Modes: Modes, - DailyModes: DailyModes, - Help: HelpContent, - Import: func(data []byte) (game.Gamer, error) { return ImportModel(data) }, -} +var Definition = puzzle.NewDefinition(puzzle.DefinitionSpec{ + Name: "Nonogram", + Description: "Fill the cells to match tomographic hints.", + Modes: ModeDefinitions, + DailyModeIDs: puzzle.SelectModeIDsByIndex(ModeDefinitions, 3, 4), +}) + +var Entry = gameentry.NewEntry(gameentry.EntrySpec{ + Definition: Definition, + Help: HelpContent, + Import: game.AdaptImport(ImportModel), + Modes: Modes, + Print: PDFPrintAdapter, +}) diff --git a/nonogram/help.md b/nonogram/help.md index d6cf1a9..b5ca065 100644 --- a/nonogram/help.md +++ b/nonogram/help.md @@ -21,7 +21,7 @@ Fill cells to match row and column hints, revealing a hidden pattern. | `z` | Fill cell (hold to paint) | | `x` | Mark cell (hold to paint) | | `Backspace` | Clear cell | -| `Ctrl+H` | Toggle help bar | +| `Ctrl+H` | Toggle full help | | `Ctrl+R` | Reset puzzle | | `Escape` | Return to main menu | diff --git a/nonogram/Model.go b/nonogram/model.go similarity index 100% rename from nonogram/Model.go rename to nonogram/model.go diff --git a/nonogram/PrintAdapter.go b/nonogram/print_adapter.go similarity index 99% rename from nonogram/PrintAdapter.go rename to nonogram/print_adapter.go index 5d6343f..b876952 100644 --- a/nonogram/PrintAdapter.go +++ b/nonogram/print_adapter.go @@ -10,6 +10,8 @@ import ( type printAdapter struct{} +var PDFPrintAdapter = printAdapter{} + func (printAdapter) CanonicalGameType() string { return "Nonogram" } func (printAdapter) Aliases() []string { return []string{"nonogram"} } @@ -277,7 +279,3 @@ func drawNonogramMajorLines( pdf.Line(puzzleStartX, y, puzzleStartX+float64(width)*cellSize, y) } } - -func init() { - pdfexport.RegisterPrintAdapter(printAdapter{}) -} diff --git a/nurikabe/README.md b/nurikabe/README.md index bfc7c91..33975ce 100644 --- a/nurikabe/README.md +++ b/nurikabe/README.md @@ -25,7 +25,6 @@ island for completion. | `Backspace` | Clear to unknown | | `Ctrl+R` | Reset puzzle | | `Ctrl+H` | Toggle full help | -| `Ctrl+E` | Toggle debug overlay | | `Escape` | Return to main menu | ## Mouse diff --git a/nurikabe/Export.go b/nurikabe/export.go similarity index 100% rename from nurikabe/Export.go rename to nurikabe/export.go diff --git a/nurikabe/Gamemode.go b/nurikabe/gamemode.go similarity index 73% rename from nurikabe/Gamemode.go rename to nurikabe/gamemode.go index b91fecb..932585e 100644 --- a/nurikabe/Gamemode.go +++ b/nurikabe/gamemode.go @@ -6,8 +6,9 @@ import ( "fmt" "math/rand/v2" - "charm.land/bubbles/v2/list" "github.com/FelineStateMachine/puzzletea/game" + "github.com/FelineStateMachine/puzzletea/gameentry" + "github.com/FelineStateMachine/puzzletea/puzzle" ) //go:embed help.md @@ -66,7 +67,7 @@ func (n NurikabeMode) SpawnSeededContext(ctx context.Context, rng *rand.Rand) (g return New(n, p) } -var Modes = []list.Item{ +var Modes = []game.Mode{ NewMode("Mini", "5x5 grid, gentle introduction.", 5, 5, 0.28, 5), NewMode("Easy", "7x7 grid, balanced logic.", 7, 7, 0.24, 7), NewMode("Medium", "9x9 grid, moderate deduction.", 9, 9, 0.20, 9), @@ -74,17 +75,20 @@ var Modes = []list.Item{ NewMode("Expert", "12x12 grid, sparse clues and long chains.", 12, 12, 0.14, 12), } -var DailyModes = []list.Item{ - Modes[1], // Easy - Modes[2], // Medium -} +var ModeDefinitions = gameentry.BuildModeDefs(Modes) -var Definition = game.Definition{ - Name: "Nurikabe", - Description: "Split the land while keeping one connected sea.", - Aliases: []string{"islands", "sea"}, - Modes: Modes, - DailyModes: DailyModes, - Help: HelpContent, - Import: func(data []byte) (game.Gamer, error) { return ImportModel(data) }, -} +var Definition = puzzle.NewDefinition(puzzle.DefinitionSpec{ + Name: "Nurikabe", + Description: "Split the land while keeping one connected sea.", + Aliases: []string{"islands", "sea"}, + Modes: ModeDefinitions, + DailyModeIDs: puzzle.SelectModeIDsByIndex(ModeDefinitions, 1, 2), +}) + +var Entry = gameentry.NewEntry(gameentry.EntrySpec{ + Definition: Definition, + Help: HelpContent, + Import: game.AdaptImport(ImportModel), + Modes: Modes, + Print: PDFPrintAdapter, +}) diff --git a/nurikabe/help.md b/nurikabe/help.md index 01b4e08..ac75c08 100644 --- a/nurikabe/help.md +++ b/nurikabe/help.md @@ -21,7 +21,7 @@ remaining undecided non-sea cells count as island. | `x` | Set sea | | `z` | Set island | | `Backspace` | Clear to unknown | -| `Ctrl+H` | Toggle help bar | +| `Ctrl+H` | Toggle full help | | `Ctrl+R` | Reset puzzle | | `Escape` | Return to main menu | diff --git a/nurikabe/Model.go b/nurikabe/model.go similarity index 100% rename from nurikabe/Model.go rename to nurikabe/model.go diff --git a/nurikabe/PrintAdapter.go b/nurikabe/print_adapter.go similarity index 97% rename from nurikabe/PrintAdapter.go rename to nurikabe/print_adapter.go index 8e720cf..9e638a2 100644 --- a/nurikabe/PrintAdapter.go +++ b/nurikabe/print_adapter.go @@ -10,6 +10,8 @@ import ( type printAdapter struct{} +var PDFPrintAdapter = printAdapter{} + func (printAdapter) CanonicalGameType() string { return "Nurikabe" } func (printAdapter) Aliases() []string { return []string{"nurikabe"} } @@ -95,7 +97,3 @@ func drawNurikabeClue(pdf *fpdf.Fpdf, x, y, cellSize float64, value int) { pdf.SetXY(x, y+(cellSize-lineH)/2) pdf.CellFormat(cellSize, lineH, text, "", 0, "C", false, 0, "") } - -func init() { - pdfexport.RegisterPrintAdapter(printAdapter{}) -} diff --git a/pdfexport/cover_art.go b/pdfexport/cover_art.go deleted file mode 100644 index 6f255d0..0000000 --- a/pdfexport/cover_art.go +++ /dev/null @@ -1,1236 +0,0 @@ -package pdfexport - -import ( - "bytes" - "encoding/binary" - "fmt" - "hash/fnv" - "image" - "image/color" - "image/png" - "math" - "math/rand" - "strings" - "time" - - "codeberg.org/go-pdf/fpdf" -) - -type coverVec2 struct { - x float64 - y float64 -} - -type coverGlow struct { - center coverVec2 - spread float64 - strength float64 - col color.RGBA -} - -type coverFieldProfile struct { - freqX float64 - freqY float64 - freqMixX float64 - freqMixY float64 - freqCurlX float64 - freqCurlY float64 - ampX float64 - ampY float64 - ampMix float64 - ampCurl float64 - phaseX float64 - phaseY float64 - phaseMix float64 - phaseCurl float64 - swirl float64 - shear float64 - pinch float64 - pivot coverVec2 -} - -type coverFlowLayer struct { - count int - steps int - step float64 - alpha uint8 - radius float64 - drift float64 - phase float64 - waveXFreq float64 - waveYFreq float64 - waveXAmp float64 - waveYAmp float64 - a color.RGBA - b color.RGBA -} - -type coverLatticeProfile struct { - enabled bool - layout int - cols int - rows int - neighbors int - jitterX float64 - jitterY float64 - center coverVec2 - radialWarp float64 - edge color.RGBA - nodeOuter color.RGBA - nodeInner color.RGBA - nodeSize float64 - coreSize float64 -} - -type coverOrbitProfile struct { - enabled bool - center coverVec2 - count int - radiusStart float64 - radiusStep float64 - arcCoverage float64 - segmentsMin int - segmentsJitter int - eccentricity float64 - wobble float64 - dotSize float64 - colorA color.RGBA - colorB color.RGBA - alphaBase uint8 - alphaStep uint8 -} - -type coverMarkProfile struct { - enabled bool - count int - gridW int - gridH int - jitter float64 - sizeMin float64 - sizeMax float64 - alphaBase uint8 - alphaRange uint8 - colorA color.RGBA - colorB color.RGBA - cutout color.RGBA -} - -type coverPalette struct { - bgTop color.RGBA - bgMid color.RGBA - bgBottom color.RGBA - accentA color.RGBA - accentB color.RGBA - ink color.RGBA - nodeOuter color.RGBA - nodeInner color.RGBA - markCutout color.RGBA - grainWarm color.RGBA - grainCool color.RGBA -} - -type coverPaletteFamily struct { - name string - bgShift [3]float64 - accentShift [2]float64 - satBias float64 - valueBias float64 -} - -type coverArchetype uint8 - -const ( - coverArchetypeConstellation coverArchetype = iota - coverArchetypeVortex - coverArchetypeBands - coverArchetypeDriftField - coverArchetypeRadialMesh - coverArchetypeSparseGlyph - coverArchetypeCount -) - -type coverModifier uint8 - -const ( - coverModifierDenseFlow coverModifier = iota - coverModifierHighOrbit - coverModifierQuietLattice - coverModifierMicroMarks - coverModifierGrainHeavy - coverModifierNegativeSpace -) - -var coverPaletteFamilies = []coverPaletteFamily{ - { - name: "tropical", - bgShift: [3]float64{0.10, 0.02, -0.14}, - accentShift: [2]float64{0.33, 0.52}, - satBias: 0.08, - valueBias: 0.03, - }, - { - name: "sunset", - bgShift: [3]float64{0.05, -0.05, -0.17}, - accentShift: [2]float64{0.19, 0.38}, - satBias: 0.10, - valueBias: 0.04, - }, - { - name: "electric-mineral", - bgShift: [3]float64{-0.16, -0.08, 0.01}, - accentShift: [2]float64{0.36, -0.28}, - satBias: 0.13, - valueBias: -0.02, - }, - { - name: "aurora", - bgShift: [3]float64{0.22, 0.10, -0.09}, - accentShift: [2]float64{0.45, 0.62}, - satBias: 0.11, - valueBias: 0.02, - }, - { - name: "ember", - bgShift: [3]float64{0.00, -0.11, -0.22}, - accentShift: [2]float64{-0.36, 0.11}, - satBias: 0.09, - valueBias: -0.03, - }, - { - name: "citrus-marine", - bgShift: [3]float64{0.14, 0.03, -0.13}, - accentShift: [2]float64{0.30, -0.22}, - satBias: 0.12, - valueBias: 0.01, - }, -} - -type coverArtDirection struct { - motif int - top color.RGBA - mid color.RGBA - bottom color.RGBA - verticalCurve float64 - glows []coverGlow - field coverFieldProfile - flowLayers []coverFlowLayer - lattice coverLatticeProfile - orbit coverOrbitProfile - marks coverMarkProfile - grainCount int - grainWarm color.RGBA - grainCool color.RGBA -} - -func drawCoverArtworkImage( - pdf *fpdf.Fpdf, - scene rectMM, - seedText string, - variant string, - base RGB, -) { - seedText = strings.TrimSpace(seedText) - if seedText == "" { - seedText = "puzzletea" + time.Now().String() - } - if strings.TrimSpace(variant) == "" { - variant = "front" - } - imageName := fmt.Sprintf( - "puzzletea-cover-artwork-%016x", - coverSeedHash(seedText+"|"+variant), - ) - artPNG := renderCoverArtworkPNG(seedText, variant, base) - options := fpdf.ImageOptions{ - ImageType: "PNG", - ReadDpi: true, - } - pdf.RegisterImageOptionsReader(imageName, options, bytes.NewReader(artPNG)) - pdf.ImageOptions(imageName, scene.x, scene.y, scene.w, scene.h, false, options, 0, "") -} - -func renderCoverArtworkPNG(seedText, variant string, base RGB) []byte { - const ( - width = 1200 - height = 1400 - ) - seed := coverSeedHash(seedText + "|" + variant) - direction := newCoverArtDirection(seed, base) - img := image.NewRGBA(image.Rect(0, 0, width, height)) - - paintCoverBackground(img, width, height, direction) - drawCoverFlowTrails(img, width, height, direction, coverStreamRNG(seed, "flow")) - drawCoverPuzzleLattice(img, width, height, direction, coverStreamRNG(seed, "lattice")) - drawCoverOrbitBands(img, width, height, direction, coverStreamRNG(seed, "orbit")) - drawCoverSeedMarks(img, width, height, direction, coverStreamRNG(seed, "marks")) - drawCoverFilmGrain(img, width, height, direction, coverStreamRNG(seed, "grain")) - - var out bytes.Buffer - if err := png.Encode(&out, img); err != nil { - return []byte{} - } - return out.Bytes() -} - -func newCoverArtDirection(seed uint64, base RGB) coverArtDirection { - paletteRNG := coverStreamRNG(seed, "palette") - structureRNG := coverStreamRNG(seed, "structure") - palette := newCoverPalette(base, paletteRNG) - primary, modifiers := pickCoverComposition(structureRNG) - - glows := make([]coverGlow, 0, 5) - anchorColor := palette.accentA - if structureRNG.Intn(2) == 0 { - anchorColor = palette.accentB - } - glows = append(glows, coverGlow{ - center: coverVec2{ - x: 0.12 + structureRNG.Float64()*0.76, - y: 0.10 + structureRNG.Float64()*0.78, - }, - spread: 0.24 + structureRNG.Float64()*0.30, - strength: 0.46 + structureRNG.Float64()*0.28, - col: jitterRGBA(anchorColor, structureRNG, 26), - }) - - glowCount := 2 + structureRNG.Intn(3) - for i := range glowCount { - col := palette.accentA - if i%2 == 1 { - col = palette.accentB - } - col = jitterRGBA(col, structureRNG, 28) - glows = append(glows, coverGlow{ - center: coverVec2{ - x: 0.06 + structureRNG.Float64()*0.88, - y: 0.08 + structureRNG.Float64()*0.84, - }, - spread: 0.42 + structureRNG.Float64()*0.70, - strength: 0.12 + structureRNG.Float64()*0.28, - col: col, - }) - } - - field := coverFieldProfile{ - freqX: 3.8 + structureRNG.Float64()*6.8, - freqY: 3.6 + structureRNG.Float64()*6.5, - freqMixX: 0.5 + structureRNG.Float64()*1.8, - freqMixY: 0.5 + structureRNG.Float64()*1.8, - freqCurlX: 0.4 + structureRNG.Float64()*2.0, - freqCurlY: 0.4 + structureRNG.Float64()*2.0, - ampX: 0.60 + structureRNG.Float64()*1.00, - ampY: 0.60 + structureRNG.Float64()*1.00, - ampMix: 0.26 + structureRNG.Float64()*0.92, - ampCurl: 0.24 + structureRNG.Float64()*0.88, - phaseX: 0.22 + structureRNG.Float64()*1.45, - phaseY: 0.22 + structureRNG.Float64()*1.45, - phaseMix: 0.20 + structureRNG.Float64()*1.20, - phaseCurl: 0.20 + structureRNG.Float64()*1.20, - swirl: (structureRNG.Float64()*2 - 1) * 0.40, - shear: (structureRNG.Float64()*2 - 1) * 0.36, - pinch: (structureRNG.Float64()*2 - 1) * 0.56, - pivot: coverVec2{ - x: 0.20 + structureRNG.Float64()*0.60, - y: 0.18 + structureRNG.Float64()*0.64, - }, - } - - flowLayers := make([]coverFlowLayer, 0, 4) - layerCount := 2 + structureRNG.Intn(3) - for i := range layerCount { - t := float64(i) / float64(maxInt(1, layerCount-1)) - a := jitterRGBA(lerpRGB(palette.accentA, palette.bgMid, 0.28+t*0.42), structureRNG, 24) - b := jitterRGBA(lerpRGB(palette.accentB, palette.bgTop, 0.16+t*0.38), structureRNG, 24) - flowLayers = append(flowLayers, coverFlowLayer{ - count: 220 + structureRNG.Intn(360), - steps: 96 + structureRNG.Intn(132), - step: 1.10 + structureRNG.Float64()*1.80, - alpha: uint8(22 + structureRNG.Intn(42)), - radius: 0.78 + structureRNG.Float64()*0.98, - drift: 0.40 + structureRNG.Float64()*1.72, - phase: structureRNG.Float64() * math.Pi * 2, - waveXFreq: 4.6 + structureRNG.Float64()*7.8, - waveYFreq: 4.6 + structureRNG.Float64()*7.8, - waveXAmp: 0.06 + structureRNG.Float64()*0.34, - waveYAmp: 0.06 + structureRNG.Float64()*0.34, - a: a, - b: b, - }) - } - - lattice := coverLatticeProfile{ - enabled: true, - layout: structureRNG.Intn(3), - cols: 7 + structureRNG.Intn(6), - rows: 6 + structureRNG.Intn(7), - neighbors: 2 + structureRNG.Intn(3), - jitterX: 24 + structureRNG.Float64()*48, - jitterY: 28 + structureRNG.Float64()*56, - center: coverVec2{x: 0.28 + structureRNG.Float64()*0.44, y: 0.24 + structureRNG.Float64()*0.50}, - radialWarp: (structureRNG.Float64()*2 - 1) * 0.40, - edge: color.RGBA{R: palette.ink.R, G: palette.ink.G, B: palette.ink.B, A: uint8(52 + structureRNG.Intn(76))}, - nodeOuter: palette.nodeOuter, - nodeInner: palette.nodeInner, - nodeSize: 3.3 + structureRNG.Float64()*3.8, - coreSize: 1.4 + structureRNG.Float64()*1.7, - } - - orbit := coverOrbitProfile{ - enabled: true, - center: coverVec2{x: 0.30 + structureRNG.Float64()*0.38, y: 0.26 + structureRNG.Float64()*0.38}, - count: 10 + structureRNG.Intn(18), - radiusStart: 36 + structureRNG.Float64()*52, - radiusStep: 13 + structureRNG.Float64()*20, - arcCoverage: 0.48 + structureRNG.Float64()*0.48, - segmentsMin: 34 + structureRNG.Intn(40), - segmentsJitter: 42 + structureRNG.Intn(56), - eccentricity: 0.66 + structureRNG.Float64()*0.56, - wobble: 2.6 + structureRNG.Float64()*8.8, - dotSize: 0.84 + structureRNG.Float64()*0.86, - colorA: jitterRGBA(lerpRGB(palette.accentA, palette.bgTop, 0.22), structureRNG, 18), - colorB: jitterRGBA(lerpRGB(palette.accentB, palette.bgMid, 0.34), structureRNG, 18), - alphaBase: uint8(18 + structureRNG.Intn(28)), - alphaStep: uint8(1 + structureRNG.Intn(3)), - } - - marks := coverMarkProfile{ - enabled: true, - count: 28 + structureRNG.Intn(58), - gridW: 8 + structureRNG.Intn(9), - gridH: 10 + structureRNG.Intn(10), - jitter: 8 + structureRNG.Float64()*30, - sizeMin: 1.7 + structureRNG.Float64()*1.5, - sizeMax: 3.4 + structureRNG.Float64()*3.0, - alphaBase: uint8(40 + structureRNG.Intn(40)), - alphaRange: uint8(20 + structureRNG.Intn(30)), - colorA: jitterRGBA(lerpRGB(palette.accentA, palette.bgTop, 0.18), structureRNG, 22), - colorB: jitterRGBA(lerpRGB(palette.accentB, palette.bgMid, 0.30), structureRNG, 22), - cutout: palette.markCutout, - } - - direction := coverArtDirection{ - motif: int(primary), - top: palette.bgTop, - mid: palette.bgMid, - bottom: palette.bgBottom, - verticalCurve: 1.18 + structureRNG.Float64()*1.14, - glows: glows, - field: field, - flowLayers: flowLayers, - lattice: lattice, - orbit: orbit, - marks: marks, - grainCount: 34000 + structureRNG.Intn(32000), - grainWarm: palette.grainWarm, - grainCool: palette.grainCool, - } - - applyCoverPrimaryArchetype(&direction, primary, structureRNG) - for _, modifier := range modifiers { - applyCoverModifier(&direction, modifier, structureRNG) - } - - return direction -} - -func coverCompositionForSeed(seed uint64) (coverArchetype, []coverModifier) { - structureRNG := coverStreamRNG(seed, "structure") - return pickCoverComposition(structureRNG) -} - -func pickCoverComposition(rng *rand.Rand) (coverArchetype, []coverModifier) { - primary := pickWeightedCoverArchetype(rng) - modifierCount := 0 - roll := rng.Float64() - switch { - case roll < 0.58: - modifierCount = 1 - default: - modifierCount = 2 - } - - modifierPool := []coverModifier{ - coverModifierDenseFlow, - coverModifierHighOrbit, - coverModifierQuietLattice, - coverModifierMicroMarks, - coverModifierGrainHeavy, - coverModifierNegativeSpace, - } - rng.Shuffle(len(modifierPool), func(i, j int) { - modifierPool[i], modifierPool[j] = modifierPool[j], modifierPool[i] - }) - - modifiers := make([]coverModifier, 0, modifierCount) - for _, modifier := range modifierPool { - if len(modifiers) >= modifierCount { - break - } - if primary == coverArchetypeSparseGlyph && modifier == coverModifierNegativeSpace { - continue - } - if primary == coverArchetypeBands && modifier == coverModifierHighOrbit { - continue - } - modifiers = append(modifiers, modifier) - } - return primary, modifiers -} - -func pickWeightedCoverArchetype(rng *rand.Rand) coverArchetype { - weights := [...]int{18, 16, 14, 18, 17, 17} - total := 0 - for _, weight := range weights { - total += weight - } - pick := rng.Intn(total) - for idx, weight := range weights { - if pick < weight { - return coverArchetype(idx) - } - pick -= weight - } - return coverArchetypeSparseGlyph -} - -func newCoverPalette(base RGB, rng *rand.Rand) coverPalette { - baseColor := rgbToColor(base) - baseHue, baseSat, baseVal := rgbToHSV(baseColor) - family := coverPaletteFamilies[rng.Intn(len(coverPaletteFamilies))] - baseHue = wrapHue(baseHue + randCentered(rng, 0.32)) - - baseSat = clamp(0.14, 0.50, baseSat*0.62+0.11+family.satBias*0.18+rng.Float64()*0.07) - baseVal = clamp(0.34, 0.86, baseVal*0.84+0.12+family.valueBias*0.15+randCentered(rng, 0.05)) - - top := hsvToRGB( - wrapHue(baseHue+family.bgShift[0]+randCentered(rng, 0.10)), - clamp(0.14, 0.34, baseSat*0.50+0.04+family.satBias*0.28+rng.Float64()*0.08), - clamp(0.74, 0.98, baseVal+0.16+family.valueBias*0.24+rng.Float64()*0.14), - ) - mid := hsvToRGB( - wrapHue(baseHue+family.bgShift[1]+randCentered(rng, 0.14)), - clamp(0.20, 0.50, baseSat*0.74+0.02+family.satBias*0.40+rng.Float64()*0.10), - clamp(0.46, 0.88, baseVal-0.02+family.valueBias*0.12+randCentered(rng, 0.12)), - ) - bottom := hsvToRGB( - wrapHue(baseHue+family.bgShift[2]+randCentered(rng, 0.16)), - clamp(0.18, 0.54, baseSat*0.82+0.01+family.satBias*0.35+rng.Float64()*0.10), - clamp(0.08, 0.36, baseVal*0.32+0.02+family.valueBias*0.10+randCentered(rng, 0.10)), - ) - accentA := hsvToRGB( - wrapHue(baseHue+family.accentShift[0]+randCentered(rng, 0.18)), - clamp(0.30, 0.62, baseSat+0.12+family.satBias*0.40+rng.Float64()*0.15), - clamp(0.62, 0.96, baseVal+0.14+rng.Float64()*0.12), - ) - accentB := hsvToRGB( - wrapHue(baseHue+family.accentShift[1]+randCentered(rng, 0.20)), - clamp(0.28, 0.60, baseSat+0.10+family.satBias*0.38+rng.Float64()*0.16), - clamp(0.56, 0.92, baseVal+0.08+rng.Float64()*0.12), - ) - ink := hsvToRGB( - wrapHue(baseHue+0.50+family.bgShift[2]*0.35), - clamp(0.22, 0.44, 0.24+baseSat*0.22), - clamp(0.06, 0.22, 0.12+(1-baseVal)*0.10), - ) - - return coverPalette{ - bgTop: top, - bgMid: mid, - bgBottom: bottom, - accentA: accentA, - accentB: accentB, - ink: ink, - nodeOuter: blendRGB(top, color.RGBA{R: 255, G: 244, B: 220, A: 255}, 0.26), - nodeInner: blendRGB(ink, bottom, 0.34), - markCutout: blendRGB(bottom, color.RGBA{R: 6, G: 11, B: 20, A: 255}, 0.30), - grainWarm: blendRGB(top, color.RGBA{R: 255, G: 248, B: 232, A: 255}, 0.36), - grainCool: blendRGB(bottom, ink, 0.44), - } -} - -func applyCoverPrimaryArchetype( - direction *coverArtDirection, - primary coverArchetype, - rng *rand.Rand, -) { - switch primary { - case coverArchetypeConstellation: - direction.lattice.layout = 0 - direction.lattice.neighbors = minInt(5, direction.lattice.neighbors+1) - direction.lattice.edge.A = minUint8(160, direction.lattice.edge.A+22) - for i := range direction.flowLayers { - direction.flowLayers[i].count = maxInt(160, int(float64(direction.flowLayers[i].count)*0.76)) - direction.flowLayers[i].alpha = maxUint8(16, direction.flowLayers[i].alpha-6) - } - direction.orbit.enabled = false - direction.orbit.count = maxInt(4, direction.orbit.count/2) - direction.orbit.arcCoverage = clamp01(direction.orbit.arcCoverage*0.72 + 0.12) - direction.marks.count += 12 - direction.field.swirl *= 0.88 - case coverArchetypeVortex: - direction.lattice.enabled = false - direction.orbit.count += 14 - direction.orbit.arcCoverage = clamp01(direction.orbit.arcCoverage*1.20 + 0.08) - direction.orbit.radiusStep *= 0.84 - direction.field.swirl *= 1.42 - for i := range direction.flowLayers { - direction.flowLayers[i].alpha = minUint8(86, direction.flowLayers[i].alpha+10) - direction.flowLayers[i].drift *= 1.18 - } - direction.marks.count = maxInt(8, direction.marks.count-16) - case coverArchetypeBands: - direction.lattice.layout = 2 - direction.lattice.enabled = false - direction.orbit.enabled = false - direction.orbit.count = maxInt(5, direction.orbit.count/2) - direction.field.shear *= 0.44 - direction.field.pinch *= 0.74 - for i := range direction.flowLayers { - direction.flowLayers[i].waveYFreq = 1.8 + rng.Float64()*2.3 - direction.flowLayers[i].waveYAmp += 0.14 + rng.Float64()*0.16 - direction.flowLayers[i].waveXAmp *= 0.66 - direction.flowLayers[i].step *= 0.94 - direction.flowLayers[i].count = maxInt(180, int(float64(direction.flowLayers[i].count)*0.84)) - } - direction.marks.count = maxInt(8, direction.marks.count-14) - case coverArchetypeDriftField: - direction.lattice.enabled = false - for i := range direction.flowLayers { - direction.flowLayers[i].count += 130 - direction.flowLayers[i].steps += 18 - direction.flowLayers[i].alpha = minUint8(90, direction.flowLayers[i].alpha+12) - direction.flowLayers[i].drift *= 1.24 - } - direction.orbit.count = maxInt(8, direction.orbit.count-4) - direction.lattice.neighbors = maxInt(2, direction.lattice.neighbors-1) - direction.marks.count = maxInt(10, direction.marks.count-12) - direction.grainCount += 5000 - case coverArchetypeRadialMesh: - direction.lattice.layout = 1 - direction.lattice.rows += 2 - direction.lattice.cols += 1 - direction.lattice.neighbors = minInt(5, direction.lattice.neighbors+1) - direction.lattice.center = coverVec2{ - x: 0.44 + rng.Float64()*0.12, - y: 0.38 + rng.Float64()*0.14, - } - direction.orbit.enabled = true - direction.orbit.count += 8 - direction.orbit.eccentricity = clamp(0.56, 1.46, direction.orbit.eccentricity*1.16) - direction.field.pinch *= 1.24 - for i := range direction.flowLayers { - direction.flowLayers[i].count = maxInt(110, int(float64(direction.flowLayers[i].count)*0.45)) - direction.flowLayers[i].alpha = maxUint8(14, direction.flowLayers[i].alpha-6) - } - direction.marks.count = maxInt(10, direction.marks.count-14) - case coverArchetypeSparseGlyph: - for i := range direction.flowLayers { - direction.flowLayers[i].count = maxInt(120, int(float64(direction.flowLayers[i].count)*0.55)) - direction.flowLayers[i].steps = maxInt(72, int(float64(direction.flowLayers[i].steps)*0.70)) - direction.flowLayers[i].alpha = maxUint8(12, direction.flowLayers[i].alpha-8) - } - direction.lattice.enabled = rng.Float64() > 0.68 - if direction.lattice.enabled { - direction.lattice.neighbors = 2 - direction.lattice.edge.A = maxUint8(14, direction.lattice.edge.A-26) - } - direction.orbit.enabled = rng.Float64() > 0.80 - direction.orbit.count = maxInt(5, direction.orbit.count/2) - direction.orbit.arcCoverage *= 0.66 - direction.marks.count += 40 - direction.marks.sizeMax += 1.5 - direction.marks.jitter *= 0.76 - direction.field.swirl *= 0.72 - direction.grainCount += 9000 - } -} - -func applyCoverModifier( - direction *coverArtDirection, - modifier coverModifier, - rng *rand.Rand, -) { - switch modifier { - case coverModifierDenseFlow: - for i := range direction.flowLayers { - direction.flowLayers[i].count += 96 - direction.flowLayers[i].steps += 16 - direction.flowLayers[i].alpha = minUint8(94, direction.flowLayers[i].alpha+10) - } - direction.grainCount += 3000 - case coverModifierHighOrbit: - direction.orbit.enabled = true - direction.orbit.count += 10 - direction.orbit.arcCoverage = clamp01(direction.orbit.arcCoverage*1.15 + 0.05) - direction.orbit.dotSize += 0.12 - direction.orbit.alphaBase = minUint8(120, direction.orbit.alphaBase+6) - case coverModifierQuietLattice: - if direction.lattice.enabled { - direction.lattice.edge.A = maxUint8(12, direction.lattice.edge.A-30) - direction.lattice.neighbors = maxInt(2, direction.lattice.neighbors-1) - } - if rng.Float64() < 0.35 { - direction.lattice.enabled = false - } - case coverModifierMicroMarks: - direction.marks.count += 26 - direction.marks.sizeMin = clamp(0.8, 4.0, direction.marks.sizeMin*0.78) - direction.marks.sizeMax = clamp(1.2, 5.2, direction.marks.sizeMax*0.74) - direction.marks.jitter *= 0.86 - direction.marks.alphaBase = minUint8(108, direction.marks.alphaBase+8) - case coverModifierGrainHeavy: - direction.grainCount += 12000 - direction.grainWarm = blendRGB(direction.grainWarm, color.RGBA{R: 255, G: 250, B: 240, A: 255}, 0.12) - case coverModifierNegativeSpace: - for i := range direction.flowLayers { - direction.flowLayers[i].count = maxInt(140, int(float64(direction.flowLayers[i].count)*0.62)) - direction.flowLayers[i].alpha = maxUint8(12, direction.flowLayers[i].alpha-8) - } - direction.orbit.count = maxInt(4, direction.orbit.count-6) - direction.orbit.alphaBase = maxUint8(8, direction.orbit.alphaBase-4) - direction.marks.count = maxInt(10, direction.marks.count-12) - direction.verticalCurve = clamp(1.0, 2.8, direction.verticalCurve+0.26) - for i := range direction.glows { - direction.glows[i].strength *= 0.72 - } - } -} - -func randCentered(rng *rand.Rand, spread float64) float64 { - return (rng.Float64()*2 - 1) * spread -} - -func paintCoverBackground(img *image.RGBA, w, h int, direction coverArtDirection) { - for y := range h { - ty := float64(y) / float64(h-1) - vertical := blendRGB( - lerpRGB(direction.top, direction.mid, ty*1.12), - direction.bottom, - powClamp(ty, direction.verticalCurve), - ) - for x := range w { - tx := float64(x) / float64(w-1) - col := vertical - for _, glow := range direction.glows { - amount := radialFalloff(tx, ty, glow.center.x, glow.center.y, glow.spread) - col = blendRGB(col, glow.col, amount*glow.strength) - } - img.SetRGBA(x, y, col) - } - } -} - -func drawCoverFlowTrails( - img *image.RGBA, - w, h int, - direction coverArtDirection, - rng *rand.Rand, -) { - for _, layer := range direction.flowLayers { - for i := 0; i < layer.count; i++ { - x := rng.Float64() * float64(w) - y := rng.Float64() * float64(h) - for step := 0; step < layer.steps; step++ { - nx := x / float64(w) - ny := y / float64(h) - angle := coverFieldAngle(nx, ny, direction.field, layer.phase) - angle += math.Sin((ny+layer.phase)*layer.waveYFreq) * layer.waveYAmp - angle += math.Cos((nx-layer.phase)*layer.waveXFreq) * layer.waveXAmp - - x += math.Cos(angle) * layer.step - y += math.Sin(angle) * layer.step - x += math.Cos((ny-layer.phase)*math.Pi*2) * layer.drift * 0.04 - y += math.Sin((nx+layer.phase)*math.Pi*2) * layer.drift * 0.08 - if x < 1 || x >= float64(w-1) || y < 1 || y >= float64(h-1) { - break - } - t := float64(step) / float64(layer.steps) - c := lerpRGB(layer.a, layer.b, t) - c.A = layer.alpha - drawDisc(img, x, y, layer.radius, c) - } - } - } -} - -func drawCoverPuzzleLattice( - img *image.RGBA, - w, h int, - direction coverArtDirection, - rng *rand.Rand, -) { - if !direction.lattice.enabled { - return - } - nodes := buildCoverLatticeNodes(w, h, direction.lattice, rng) - if len(nodes) == 0 { - return - } - - for i := range nodes { - for _, j := range nearestN(nodes, i, direction.lattice.neighbors) { - if j > i { - drawLine(img, nodes[i], nodes[j], direction.lattice.edge) - } - } - } - for _, n := range nodes { - drawDisc(img, n.x, n.y, direction.lattice.nodeSize, direction.lattice.nodeOuter) - drawDisc(img, n.x, n.y, direction.lattice.coreSize, direction.lattice.nodeInner) - } -} - -func buildCoverLatticeNodes( - w, h int, - profile coverLatticeProfile, - rng *rand.Rand, -) []coverVec2 { - nodes := make([]coverVec2, 0, profile.cols*profile.rows) - switch profile.layout { - case 1: - minDim := math.Min(float64(w), float64(h)) - rings := maxInt(4, profile.rows+1) - spokes := maxInt(8, profile.cols+4) - center := coverVec2{ - x: profile.center.x * float64(w), - y: profile.center.y * float64(h), - } - for ring := 1; ring <= rings; ring++ { - ringT := float64(ring) / float64(rings+1) - radius := ringT * minDim * (0.16 + 0.62*ringT) - offset := rng.Float64() * math.Pi * 2 - for spoke := range spokes { - ang := offset + float64(spoke)/float64(spokes)*math.Pi*2 - ang += math.Sin(float64(ring)*0.9+float64(spoke)*0.45) * profile.radialWarp - jitter := (rng.Float64() - 0.5) * profile.jitterX - x := center.x + math.Cos(ang)*(radius+jitter) - y := center.y + math.Sin(ang)*(radius+jitter*0.45) - nodes = append(nodes, coverVec2{x: x, y: y}) - } - } - case 2: - total := maxInt(38, profile.cols*profile.rows) - for i := range total { - tx := (rng.Float64()*0.84 + 0.08) - ty := (rng.Float64()*0.84 + 0.08) - warpX := math.Sin(ty*math.Pi*4+float64(i)*0.22) * profile.radialWarp * 0.12 - warpY := math.Cos(tx*math.Pi*3+float64(i)*0.18) * profile.radialWarp * 0.10 - x := (tx+warpX)*float64(w) + (rng.Float64()-0.5)*profile.jitterX - y := (ty+warpY)*float64(h) + (rng.Float64()-0.5)*profile.jitterY - nodes = append(nodes, coverVec2{x: x, y: y}) - } - default: - for row := 0; row < profile.rows; row++ { - for col := 0; col < profile.cols; col++ { - x := (float64(col) + 1) / (float64(profile.cols) + 1) * float64(w) - y := (float64(row) + 1) / (float64(profile.rows) + 1) * float64(h) - x += (rng.Float64() - 0.5) * profile.jitterX - y += (rng.Float64() - 0.5) * profile.jitterY - nodes = append(nodes, coverVec2{x: x, y: y}) - } - } - } - return nodes -} - -func drawCoverOrbitBands( - img *image.RGBA, - w, h int, - direction coverArtDirection, - rng *rand.Rand, -) { - if !direction.orbit.enabled { - return - } - center := coverVec2{ - x: float64(w) * direction.orbit.center.x, - y: float64(h) * direction.orbit.center.y, - } - for i := 0; i < direction.orbit.count; i++ { - radius := direction.orbit.radiusStart + float64(i)*direction.orbit.radiusStep - start := rng.Float64() * math.Pi * 2 - sweep := direction.orbit.arcCoverage * (0.74 + rng.Float64()*0.52) * math.Pi * 2 - sweep = math.Min(sweep, math.Pi*2) - segments := direction.orbit.segmentsMin + rng.Intn(direction.orbit.segmentsJitter) - t := float64(i) / float64(maxInt(1, direction.orbit.count-1)) - col := lerpRGB(direction.orbit.colorA, direction.orbit.colorB, t) - col.A = minUint8(220, direction.orbit.alphaBase+uint8(i)*direction.orbit.alphaStep) - for s := range segments { - a := start + float64(s)/float64(segments)*sweep - jx := math.Sin(float64(i)*0.43+a*2.9) * direction.orbit.wobble - jy := math.Cos(float64(i)*0.37+a*2.3) * direction.orbit.wobble * 0.82 - x := center.x + math.Cos(a)*radius*direction.orbit.eccentricity + jx - y := center.y + math.Sin(a)*radius + jy - drawDisc(img, x, y, direction.orbit.dotSize, col) - } - } -} - -func drawCoverSeedMarks( - img *image.RGBA, - w, h int, - direction coverArtDirection, - rng *rand.Rand, -) { - if !direction.marks.enabled { - return - } - for i := 0; i < direction.marks.count; i++ { - gx := 1 + rng.Intn(direction.marks.gridW) - gy := 1 + rng.Intn(direction.marks.gridH) - x := float64(gx) / float64(direction.marks.gridW+1) * float64(w) - y := float64(gy) / float64(direction.marks.gridH+1) * float64(h) - x += (rng.Float64() - 0.5) * direction.marks.jitter - y += (rng.Float64() - 0.5) * direction.marks.jitter - - size := direction.marks.sizeMin - if direction.marks.sizeMax > direction.marks.sizeMin { - size += rng.Float64() * (direction.marks.sizeMax - direction.marks.sizeMin) - } - col := lerpRGB(direction.marks.colorA, direction.marks.colorB, rng.Float64()) - col.A = minUint8(220, direction.marks.alphaBase+uint8(rng.Intn(int(direction.marks.alphaRange)+1))) - style := (i + rng.Intn(4) + direction.motif) % 4 - switch style { - case 0: - drawDisc(img, x, y, size*0.48, col) - case 1: - drawDisc(img, x, y, size, col) - drawDisc(img, x, y, size*0.52, direction.marks.cutout) - case 2: - drawLine(img, coverVec2{x: x - size, y: y}, coverVec2{x: x + size, y: y}, col) - drawLine(img, coverVec2{x: x, y: y - size}, coverVec2{x: x, y: y + size}, col) - default: - drawLine(img, coverVec2{x: x - size, y: y - size}, coverVec2{x: x + size, y: y + size}, col) - drawLine(img, coverVec2{x: x - size, y: y + size}, coverVec2{x: x + size, y: y - size}, col) - drawDisc(img, x, y, size*0.32, direction.marks.cutout) - } - } -} - -func drawCoverFilmGrain( - img *image.RGBA, - w, h int, - direction coverArtDirection, - rng *rand.Rand, -) { - for i := 0; i < direction.grainCount; i++ { - x := rng.Intn(w) - y := rng.Intn(h) - alpha := uint8(8 + rng.Intn(18)) - if rng.Intn(2) == 0 { - blendPixel(img, x, y, color.RGBA{ - R: direction.grainWarm.R, - G: direction.grainWarm.G, - B: direction.grainWarm.B, - A: alpha, - }) - continue - } - blendPixel(img, x, y, color.RGBA{ - R: direction.grainCool.R, - G: direction.grainCool.G, - B: direction.grainCool.B, - A: alpha, - }) - } -} - -func coverFieldAngle(x, y float64, field coverFieldProfile, phase float64) float64 { - ax := math.Sin((x*field.freqX+phase*field.phaseX)*math.Pi*2) * field.ampX - ay := math.Cos((y*field.freqY-phase*field.phaseY)*math.Pi*2) * field.ampY - mix := math.Sin((x*field.freqMixX+y*field.freqMixY+phase*field.phaseMix)*math.Pi*2) * field.ampMix - curl := math.Cos((x*field.freqCurlX-y*field.freqCurlY+phase*field.phaseCurl)*math.Pi*2) * field.ampCurl - radial := math.Atan2(y-field.pivot.y, x-field.pivot.x) - distance := math.Hypot(x-field.pivot.x, y-field.pivot.y) - swirl := radial * field.swirl - pinch := (0.5 - distance) * field.pinch - shear := (x - y) * field.shear - return ax + ay + mix + curl + swirl + pinch + shear -} - -func nearestN(nodes []coverVec2, idx, n int) []int { - n = maxInt(1, n) - bestDistance := make([]float64, n) - bestIndex := make([]int, n) - for i := range bestDistance { - bestDistance[i] = math.MaxFloat64 - bestIndex[i] = -1 - } - for j := range nodes { - if j == idx { - continue - } - dx := nodes[idx].x - nodes[j].x - dy := nodes[idx].y - nodes[j].y - distance := dx*dx + dy*dy - for k := range bestDistance { - if distance >= bestDistance[k] { - continue - } - for shift := len(bestDistance) - 1; shift > k; shift-- { - bestDistance[shift] = bestDistance[shift-1] - bestIndex[shift] = bestIndex[shift-1] - } - bestDistance[k] = distance - bestIndex[k] = j - break - } - } - out := make([]int, 0, len(bestIndex)) - for _, best := range bestIndex { - if best >= 0 { - out = append(out, best) - } - } - return out -} - -func drawDisc(img *image.RGBA, cx, cy, radius float64, c color.RGBA) { - minX := int(math.Floor(cx - radius)) - maxX := int(math.Ceil(cx + radius)) - minY := int(math.Floor(cy - radius)) - maxY := int(math.Ceil(cy + radius)) - r2 := radius * radius - for y := minY; y <= maxY; y++ { - for x := minX; x <= maxX; x++ { - dx := float64(x) + 0.5 - cx - dy := float64(y) + 0.5 - cy - if dx*dx+dy*dy <= r2 { - blendPixel(img, x, y, c) - } - } - } -} - -func drawLine(img *image.RGBA, a, b coverVec2, c color.RGBA) { - dx := b.x - a.x - dy := b.y - a.y - steps := int(math.Max(math.Abs(dx), math.Abs(dy))) - if steps <= 0 { - blendPixel(img, int(a.x), int(a.y), c) - return - } - for i := 0; i <= steps; i++ { - t := float64(i) / float64(steps) - drawDisc(img, a.x+dx*t, a.y+dy*t, 0.82, c) - } -} - -func blendPixel(img *image.RGBA, x, y int, src color.RGBA) { - if !image.Pt(x, y).In(img.Rect) { - return - } - dst := img.RGBAAt(x, y) - alpha := float64(src.A) / 255 - inv := 1 - alpha - img.SetRGBA(x, y, color.RGBA{ - R: uint8(float64(src.R)*alpha + float64(dst.R)*inv), - G: uint8(float64(src.G)*alpha + float64(dst.G)*inv), - B: uint8(float64(src.B)*alpha + float64(dst.B)*inv), - A: 255, - }) -} - -func rgbToColor(c RGB) color.RGBA { - return color.RGBA{R: c.R, G: c.G, B: c.B, A: 255} -} - -func rgbToHSV(c color.RGBA) (h, s, v float64) { - r := float64(c.R) / 255 - g := float64(c.G) / 255 - b := float64(c.B) / 255 - maxC := math.Max(r, math.Max(g, b)) - minC := math.Min(r, math.Min(g, b)) - chroma := maxC - minC - - v = maxC - if maxC == 0 { - s = 0 - } else { - s = chroma / maxC - } - if chroma == 0 { - return 0, s, v - } - - switch maxC { - case r: - h = math.Mod((g-b)/chroma, 6) - case g: - h = (b-r)/chroma + 2 - default: - h = (r-g)/chroma + 4 - } - h /= 6 - if h < 0 { - h += 1 - } - return h, s, v -} - -func hsvToRGB(h, s, v float64) color.RGBA { - h = wrapHue(h) - s = clamp01(s) - v = clamp01(v) - if s == 0 { - ch := uint8(math.Round(v * 255)) - return color.RGBA{R: ch, G: ch, B: ch, A: 255} - } - - h6 := h * 6 - segment := math.Floor(h6) - f := h6 - segment - p := v * (1 - s) - q := v * (1 - s*f) - t := v * (1 - s*(1-f)) - - var r, g, b float64 - switch int(segment) % 6 { - case 0: - r, g, b = v, t, p - case 1: - r, g, b = q, v, p - case 2: - r, g, b = p, v, t - case 3: - r, g, b = p, q, v - case 4: - r, g, b = t, p, v - default: - r, g, b = v, p, q - } - - return color.RGBA{ - R: uint8(math.Round(clamp01(r) * 255)), - G: uint8(math.Round(clamp01(g) * 255)), - B: uint8(math.Round(clamp01(b) * 255)), - A: 255, - } -} - -func wrapHue(h float64) float64 { - if h >= 0 && h < 1 { - return h - } - h = math.Mod(h, 1) - if h < 0 { - h += 1 - } - return h -} - -func lerpRGB(a, b color.RGBA, t float64) color.RGBA { - t = clamp01(t) - return color.RGBA{ - R: uint8(float64(a.R) + (float64(b.R)-float64(a.R))*t), - G: uint8(float64(a.G) + (float64(b.G)-float64(a.G))*t), - B: uint8(float64(a.B) + (float64(b.B)-float64(a.B))*t), - A: 255, - } -} - -func blendRGB(base, top color.RGBA, amount float64) color.RGBA { - return lerpRGB(base, top, clamp01(amount)) -} - -func radialFalloff(x, y, cx, cy, spread float64) float64 { - dx := x - cx - dy := y - cy - distance := math.Sqrt(dx*dx + dy*dy) - if distance >= spread { - return 0 - } - q := 1 - distance/spread - return q * q -} - -func powClamp(v, p float64) float64 { - return math.Pow(clamp01(v), p) -} - -func clamp01(v float64) float64 { - if v < 0 { - return 0 - } - if v > 1 { - return 1 - } - return v -} - -func clamp(min, max, v float64) float64 { - if v < min { - return min - } - if v > max { - return max - } - return v -} - -func maxInt(a, b int) int { - if a > b { - return a - } - return b -} - -func minInt(a, b int) int { - if a < b { - return a - } - return b -} - -func maxUint8(a, b uint8) uint8 { - if a > b { - return a - } - return b -} - -func minUint8(a, b uint8) uint8 { - if a < b { - return a - } - return b -} - -func jitterRGBA(c color.RGBA, rng *rand.Rand, spread int) color.RGBA { - if spread <= 0 { - return c - } - half := spread / 2 - return color.RGBA{ - R: shiftChannel(c.R, rng.Intn(spread)-half), - G: shiftChannel(c.G, rng.Intn(spread)-half), - B: shiftChannel(c.B, rng.Intn(spread)-half), - A: c.A, - } -} - -func shiftChannel(ch uint8, delta int) uint8 { - v := int(ch) + delta - if v < 0 { - return 0 - } - if v > 255 { - return 255 - } - return uint8(v) -} - -func coverStreamRNG(seed uint64, stream string) *rand.Rand { - h := fnv.New64a() - var raw [8]byte - binary.LittleEndian.PutUint64(raw[:], seed) - _, _ = h.Write(raw[:]) - _, _ = h.Write([]byte(stream)) - return rand.New(rand.NewSource(int64(h.Sum64()))) -} - -func coverSeedHash(text string) uint64 { - h := fnv.New64a() - h.Write([]byte(text)) - return h.Sum64() -} diff --git a/pdfexport/cover_art_test.go b/pdfexport/cover_art_test.go deleted file mode 100644 index af1d9aa..0000000 --- a/pdfexport/cover_art_test.go +++ /dev/null @@ -1,204 +0,0 @@ -package pdfexport - -import ( - "bytes" - "fmt" - "image" - "image/color" - "image/png" - "math" - "testing" -) - -// --- Cover art seeding (P1) --- - -func TestRenderCoverArtworkPNGDeterministic(t *testing.T) { - base := RGB{R: 86, G: 124, B: 149} - seed := "quiet-fjord" - - first := renderCoverArtworkPNG(seed, "front", base) - second := renderCoverArtworkPNG(seed, "front", base) - - if len(first) == 0 || len(second) == 0 { - t.Fatalf("renderCoverArtworkPNG returned empty output") - } - if !bytes.Equal(first, second) { - t.Fatalf("expected identical PNG bytes for identical seed and variant") - } -} - -func TestRenderCoverArtworkPNGVariesBySeedAndVariant(t *testing.T) { - if testing.Short() { - t.Skip("skipping cover art variation check in short mode") - } - - base := RGB{R: 90, G: 128, B: 154} - frontA := renderCoverArtworkPNG("quiet-fjord", "front", base) - frontB := renderCoverArtworkPNG("quiet-fjord-alt", "front", base) - backA := renderCoverArtworkPNG("quiet-fjord", "back", base) - - assertArtworkDiff(t, frontA, frontB, 0.03) - assertArtworkDiff(t, frontA, backA, 0.03) -} - -func TestCoverArtworkBatchSaturationFloor(t *testing.T) { - if testing.Short() { - t.Skip("skipping batch saturation check in short mode") - } - - base := RGB{R: 90, G: 128, B: 154} - seeds := coverSeedSamples(30) - var total float64 - for _, seed := range seeds { - img := decodePNG(t, renderCoverArtworkPNG(seed, "front", base)) - total += sampledMeanSaturation(img, 14) - } - meanSaturation := total / float64(len(seeds)) - if meanSaturation < 0.24 { - t.Fatalf("mean saturation too low: got %.4f want >= 0.2400", meanSaturation) - } - if meanSaturation > 0.36 { - t.Fatalf("mean saturation unexpectedly high: got %.4f want <= 0.3600", meanSaturation) - } -} - -func TestCoverArtworkBatchDiversityFloor(t *testing.T) { - if testing.Short() { - t.Skip("skipping batch diversity check in short mode") - } - - base := RGB{R: 90, G: 128, B: 154} - seeds := coverSeedSamples(30) - images := make([]image.Image, 0, len(seeds)) - for _, seed := range seeds { - images = append(images, decodePNG(t, renderCoverArtworkPNG(seed, "front", base))) - } - - minDiff := math.MaxFloat64 - var total float64 - var pairs int - for i := range images { - for j := i + 1; j < len(images); j++ { - diff := sampledMeanChannelDiff(images[i], images[j], 12) - if diff < minDiff { - minDiff = diff - } - total += diff - pairs++ - } - } - meanDiff := total / float64(pairs) - if meanDiff < 0.090 { - t.Fatalf("mean pairwise diff too low: got %.4f want >= 0.0900", meanDiff) - } - if minDiff < 0.040 { - t.Fatalf("minimum pairwise diff too low: got %.4f want >= 0.0400", minDiff) - } -} - -func TestCoverArtworkArchetypeCoverage(t *testing.T) { - seen := make(map[coverArchetype]bool, int(coverArchetypeCount)) - for i := 1; i <= 120; i++ { - seed := fmt.Sprintf("coverage-%03d|front", i) - archetype, _ := coverCompositionForSeed(coverSeedHash(seed)) - seen[archetype] = true - } - - if len(seen) != int(coverArchetypeCount) { - t.Fatalf("archetype coverage incomplete: got %d want %d", len(seen), coverArchetypeCount) - } -} - -func TestRenderCoverArtworkPNGHandlesLowAndHighChromaBase(t *testing.T) { - lowChroma := RGB{R: 128, G: 128, B: 128} - highChroma := RGB{R: 245, G: 62, B: 58} - - low := renderCoverArtworkPNG("chroma-check", "front", lowChroma) - high := renderCoverArtworkPNG("chroma-check", "front", highChroma) - - if len(low) == 0 || len(high) == 0 { - t.Fatalf("renderCoverArtworkPNG returned empty output for chroma check") - } - assertArtworkDiff(t, low, high, 0.03) -} - -func assertArtworkDiff(t *testing.T, left, right []byte, minMeanDiff float64) { - t.Helper() - - if bytes.Equal(left, right) { - t.Fatalf("unexpected byte-identical images for different inputs") - } - - imgA := decodePNG(t, left) - imgB := decodePNG(t, right) - diff := sampledMeanChannelDiff(imgA, imgB, 12) - if diff < minMeanDiff { - t.Fatalf("mean sampled channel diff too low: got %.4f want >= %.4f", diff, minMeanDiff) - } -} - -func decodePNG(t *testing.T, data []byte) image.Image { - t.Helper() - img, err := png.Decode(bytes.NewReader(data)) - if err != nil { - t.Fatalf("png.Decode error: %v", err) - } - return img -} - -func sampledMeanChannelDiff(a, b image.Image, step int) float64 { - bounds := a.Bounds() - if step < 1 { - step = 1 - } - - var total float64 - var samples int - for y := bounds.Min.Y; y < bounds.Max.Y; y += step { - for x := bounds.Min.X; x < bounds.Max.X; x += step { - ar, ag, ab, _ := a.At(x, y).RGBA() - br, bg, bb, _ := b.At(x, y).RGBA() - total += math.Abs(float64(ar)-float64(br)) / 65535 - total += math.Abs(float64(ag)-float64(bg)) / 65535 - total += math.Abs(float64(ab)-float64(bb)) / 65535 - samples += 3 - } - } - - if samples == 0 { - return 0 - } - return total / float64(samples) -} - -func sampledMeanSaturation(img image.Image, step int) float64 { - bounds := img.Bounds() - if step < 1 { - step = 1 - } - - var total float64 - var samples int - for y := bounds.Min.Y; y < bounds.Max.Y; y += step { - for x := bounds.Min.X; x < bounds.Max.X; x += step { - r, g, b, _ := img.At(x, y).RGBA() - c := color.RGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8), A: 255} - _, sat, _ := rgbToHSV(c) - total += sat - samples++ - } - } - - if samples == 0 { - return 0 - } - return total / float64(samples) -} - -func coverSeedSamples(n int) []string { - out := make([]string, 0, n) - for i := 1; i <= n; i++ { - out = append(out, fmt.Sprintf("test-cover-%02d", i)) - } - return out -} diff --git a/pdfexport/jsonl.go b/pdfexport/jsonl.go index 1af1913..d78845f 100644 --- a/pdfexport/jsonl.go +++ b/pdfexport/jsonl.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "strings" ) @@ -66,6 +67,7 @@ func ParseJSONLFile(path string) (PackDocument, error) { puzzles := []Puzzle{} lineNo := 0 seenAny := false + unsupportedCategories := []string{} for scanner.Scan() { lineNo++ @@ -104,6 +106,9 @@ func ParseJSONLFile(path string) (PackDocument, error) { adapter, ok := LookupPrintAdapter(category) if !ok { + if category != "" && !slices.Contains(unsupportedCategories, category) { + unsupportedCategories = append(unsupportedCategories, category) + } continue } payload, err := adapter.BuildPDFPayload(p.SaveData) @@ -142,6 +147,16 @@ func ParseJSONLFile(path string) (PackDocument, error) { if !seenAny { return PackDocument{}, fmt.Errorf("%s: input jsonl is empty", path) } + if len(puzzles) == 0 { + if len(unsupportedCategories) > 0 { + return PackDocument{}, fmt.Errorf( + "%s: no printable puzzles found; unsupported categories: %s", + path, + strings.Join(unsupportedCategories, ", "), + ) + } + return PackDocument{}, fmt.Errorf("%s: no printable puzzles found", path) + } if doc.Metadata.Count == 0 { doc.Metadata.Count = len(puzzles) } diff --git a/pdfexport/jsonl_test.go b/pdfexport/jsonl_test.go index f643c47..b8b92b0 100644 --- a/pdfexport/jsonl_test.go +++ b/pdfexport/jsonl_test.go @@ -95,6 +95,39 @@ func TestParseJSONLFileRejectsNonJSONLExtension(t *testing.T) { } } +func TestParseJSONLFileRejectsFilesWithoutPrintablePuzzles(t *testing.T) { + path := filepath.Join(t.TempDir(), "lights.jsonl") + record := JSONLRecord{ + Schema: ExportSchemaV1, + Pack: JSONLPackMeta{ + Generated: "2026-02-22T10:00:00Z", + Version: "v-test", + Category: "Lights Out", + ModeSelection: "Standard", + Count: 1, + }, + Puzzle: JSONLPuzzle{ + Index: 1, + Name: "glow-shore", + Game: "Lights Out", + Mode: "Standard", + Save: json.RawMessage(`{"size":5}`), + }, + } + writeSingleJSONLRecord(t, path, record) + + _, err := ParseJSONLFile(path) + if err == nil { + t.Fatal("expected no-printable-puzzles error") + } + if !strings.Contains(err.Error(), "no printable puzzles found") { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(err.Error(), "Lights Out") { + t.Fatalf("expected unsupported category in error, got: %v", err) + } +} + func TestParseJSONLFileHydratesTakuzuFromSave(t *testing.T) { path := filepath.Join(t.TempDir(), "takuzu-pack.jsonl") record := JSONLRecord{ @@ -401,36 +434,6 @@ func TestParseJSONLFileHydratesHashiFromSave(t *testing.T) { } } -func TestParseJSONLFileSilentlySkipsUnsupportedGame(t *testing.T) { - path := filepath.Join(t.TempDir(), "lights.jsonl") - record := JSONLRecord{ - Schema: ExportSchemaV1, - Pack: JSONLPackMeta{ - Generated: "2026-02-22T10:00:00Z", - Version: "v-test", - Category: "Lights Out", - ModeSelection: "Standard", - Count: 1, - }, - Puzzle: JSONLPuzzle{ - Index: 1, - Name: "glow-shore", - Game: "Lights Out", - Mode: "Standard", - Save: json.RawMessage(`{"size":5}`), - }, - } - writeSingleJSONLRecord(t, path, record) - - doc, err := ParseJSONLFile(path) - if err != nil { - t.Fatalf("expected silent no-op for unsupported game, got: %v", err) - } - if got := len(doc.Puzzles); got != 0 { - t.Fatalf("puzzles = %d, want 0", got) - } -} - func TestParseJSONLFileMetadataFromFirstPrintableRecord(t *testing.T) { path := filepath.Join(t.TempDir(), "mixed.jsonl") diff --git a/pdfexport/normalize.go b/pdfexport/normalize.go new file mode 100644 index 0000000..789680c --- /dev/null +++ b/pdfexport/normalize.go @@ -0,0 +1,7 @@ +package pdfexport + +import "github.com/FelineStateMachine/puzzletea/puzzle" + +func normalizeToken(s string) string { + return puzzle.NormalizeName(s) +} diff --git a/pdfexport/order.go b/pdfexport/order.go index 860e7b0..ecb71f3 100644 --- a/pdfexport/order.go +++ b/pdfexport/order.go @@ -6,6 +6,8 @@ import ( "sort" "strings" "time" + + "github.com/FelineStateMachine/puzzletea/puzzle" ) func OrderPuzzlesForPrint(puzzles []Puzzle, seed string) []Puzzle { @@ -18,10 +20,10 @@ func OrderPuzzlesForPrint(puzzles []Puzzle, seed string) []Puzzle { if ordered[i].DifficultyScore != ordered[j].DifficultyScore { return ordered[i].DifficultyScore < ordered[j].DifficultyScore } - if c := strings.Compare(normalizeToken(ordered[i].Category), normalizeToken(ordered[j].Category)); c != 0 { + if c := strings.Compare(puzzle.NormalizeName(ordered[i].Category), puzzle.NormalizeName(ordered[j].Category)); c != 0 { return c < 0 } - if c := strings.Compare(normalizeToken(ordered[i].ModeSelection), normalizeToken(ordered[j].ModeSelection)); c != 0 { + if c := strings.Compare(puzzle.NormalizeName(ordered[i].ModeSelection), puzzle.NormalizeName(ordered[j].ModeSelection)); c != 0 { return c < 0 } if c := strings.Compare(ordered[i].SourceFileName, ordered[j].SourceFileName); c != 0 { @@ -93,12 +95,5 @@ func seededRand(seed string) *rand.Rand { } func sameCategory(a, b Puzzle) bool { - return normalizeToken(a.Category) == normalizeToken(b.Category) -} - -func normalizeToken(s string) string { - s = strings.ToLower(strings.TrimSpace(s)) - s = strings.ReplaceAll(s, "-", " ") - s = strings.ReplaceAll(s, "_", " ") - return strings.Join(strings.Fields(s), " ") + return puzzle.NormalizeName(a.Category) == puzzle.NormalizeName(b.Category) } diff --git a/pdfexport/parse.go b/pdfexport/parse.go deleted file mode 100644 index dd4e22c..0000000 --- a/pdfexport/parse.go +++ /dev/null @@ -1,496 +0,0 @@ -package pdfexport - -import ( - "fmt" - "os" - "path/filepath" - "regexp" - "strconv" - "strings" - "time" -) - -var ( - puzzleHeadingPattern = regexp.MustCompile(`^##\s+(.+?)\s+-\s+(\d+)\s*$`) - nonogramRowHeaderRegex = regexp.MustCompile(`^R\d+$`) - nonogramColHeaderRegex = regexp.MustCompile(`^C\d+$`) - tableSepCellRegex = regexp.MustCompile(`^:?-{3,}:?$`) -) - -func ParseFiles(paths []string) ([]PackDocument, error) { - docs := make([]PackDocument, 0, len(paths)) - for _, path := range paths { - doc, err := ParseFile(path) - if err != nil { - return nil, err - } - docs = append(docs, doc) - } - return docs, nil -} - -func ParseFile(path string) (PackDocument, error) { - data, err := os.ReadFile(path) - if err != nil { - return PackDocument{}, fmt.Errorf("read input markdown: %w", err) - } - return ParseMarkdown(path, string(data)) -} - -func ParseMarkdown(path, content string) (PackDocument, error) { - content = strings.ReplaceAll(content, "\r\n", "\n") - content = strings.ReplaceAll(content, "\r", "\n") - lines := strings.Split(content, "\n") - - firstContentLine := firstNonEmptyLine(lines) - if firstContentLine < 0 { - return PackDocument{}, parseError(path, 1, "input markdown is empty") - } - if strings.TrimSpace(lines[firstContentLine]) != "# PuzzleTea Export" { - return PackDocument{}, parseError(path, firstContentLine+1, "expected markdown title '# PuzzleTea Export'") - } - - headings := findHeadingLines(lines) - if len(headings) == 0 { - return PackDocument{}, parseError(path, firstContentLine+1, "expected at least one puzzle section heading") - } - - meta, err := parseMetadata(lines[firstContentLine+1:headings[0]], path, firstContentLine+2) - if err != nil { - return PackDocument{}, err - } - meta.SourceFileName = filepath.Base(path) - - puzzles := make([]Puzzle, 0, len(headings)) - for i, start := range headings { - end := len(lines) - if i+1 < len(headings) { - end = headings[i+1] - } - - puzzle, err := parsePuzzleSection(lines[start:end], path, start+1, meta) - if err != nil { - return PackDocument{}, err - } - puzzles = append(puzzles, puzzle) - } - - if len(puzzles) == 0 { - return PackDocument{}, parseError(path, firstContentLine+1, "no puzzle sections were parsed") - } - - return PackDocument{ - SourcePath: path, - Metadata: meta, - Puzzles: puzzles, - }, nil -} - -func parseMetadata(lines []string, path string, startLine int) (PackMetadata, error) { - meta := PackMetadata{} - - for i, line := range lines { - trimmed := strings.TrimSpace(line) - if trimmed == "" { - continue - } - if !strings.HasPrefix(trimmed, "- ") { - continue - } - - entry := strings.TrimSpace(strings.TrimPrefix(trimmed, "- ")) - key, value, ok := strings.Cut(entry, ":") - if !ok { - continue - } - - lineNo := startLine + i - key = strings.ToLower(strings.TrimSpace(key)) - value = strings.TrimSpace(value) - - switch key { - case "generated": - meta.GeneratedRaw = value - if ts, err := time.Parse(time.RFC3339, value); err == nil { - meta.GeneratedAt = ts - } - case "version": - meta.Version = value - case "category": - meta.Category = value - case "mode selection": - meta.ModeSelection = value - case "count": - count, err := strconv.Atoi(value) - if err != nil { - return PackMetadata{}, parseError(path, lineNo, "invalid Count value %q", value) - } - meta.Count = count - case "seed": - meta.Seed = value - case "export format": - meta.Format = value - } - } - - if strings.TrimSpace(meta.Category) == "" { - return PackMetadata{}, parseError(path, startLine, "missing required metadata field: Category") - } - if strings.TrimSpace(meta.ModeSelection) == "" { - return PackMetadata{}, parseError(path, startLine, "missing required metadata field: Mode Selection") - } - - return meta, nil -} - -func parsePuzzleSection(section []string, path string, startLine int, meta PackMetadata) (Puzzle, error) { - if len(section) == 0 { - return Puzzle{}, parseError(path, startLine, "empty puzzle section") - } - - heading := strings.TrimSpace(section[0]) - matches := puzzleHeadingPattern.FindStringSubmatch(heading) - if len(matches) != 3 { - return Puzzle{}, parseError(path, startLine, "invalid puzzle heading %q", heading) - } - - index, err := strconv.Atoi(matches[2]) - if err != nil { - return Puzzle{}, parseError(path, startLine, "invalid puzzle index %q", matches[2]) - } - - bodyLines := append([]string(nil), section[1:]...) - trimSectionBody(&bodyLines) - body := strings.Join(bodyLines, "\n") - - p := Puzzle{ - SourcePath: path, - SourceFileName: meta.SourceFileName, - Category: meta.Category, - ModeSelection: meta.ModeSelection, - Name: matches[1], - Index: index, - Body: body, - } - - if strings.EqualFold(strings.TrimSpace(meta.Category), "nonogram") { - nonogram, err := parseNonogramBody(bodyLines, path, startLine+1) - if err != nil { - return Puzzle{}, err - } - p.PrintPayload = nonogram - return p, nil - } - - table, err := parseGridTableBody(bodyLines, path, startLine+1) - if err != nil { - return Puzzle{}, err - } - p.PrintPayload = table - - return p, nil -} - -func parseNonogramBody(bodyLines []string, path string, bodyStartLine int) (*NonogramData, error) { - tableLines, tableLineNumbers := findFirstMarkdownTable(bodyLines, bodyStartLine) - if len(tableLines) < 3 { - lineNo := bodyStartLine - if len(tableLineNumbers) > 0 { - lineNo = tableLineNumbers[0] - } - return nil, parseError(path, lineNo, "expected nonogram markdown table with header, separator, and data rows") - } - - header := parseTableRow(tableLines[0]) - if len(header) == 0 { - return nil, parseError(path, tableLineNumbers[0], "nonogram header row is empty") - } - - rowHintCols := 0 - colCount := 0 - for _, cell := range header { - switch { - case nonogramRowHeaderRegex.MatchString(cell): - rowHintCols++ - case nonogramColHeaderRegex.MatchString(cell): - colCount++ - } - } - if rowHintCols < 1 || colCount < 1 { - return nil, parseError(path, tableLineNumbers[0], "expected nonogram header cells like R1.. and C1..") - } - - expectedCols := rowHintCols + colCount - dataRows := make([][]string, 0, len(tableLines)-2) - for i := 2; i < len(tableLines); i++ { - cells := parseTableRow(tableLines[i]) - if len(cells) < expectedCols { - return nil, parseError(path, tableLineNumbers[i], "expected %d columns, found %d", expectedCols, len(cells)) - } - if len(cells) > expectedCols { - cells = cells[:expectedCols] - } - dataRows = append(dataRows, cells) - } - - if len(dataRows) == 0 { - return nil, parseError(path, tableLineNumbers[0], "nonogram table has no data rows") - } - - colHintRows := 0 - for _, row := range dataRows { - if rowHintPlaceholderRow(row[:rowHintCols]) { - colHintRows++ - continue - } - break - } - - height := len(dataRows) - colHintRows - if height <= 0 { - return nil, parseError(path, tableLineNumbers[len(tableLineNumbers)-1], "nonogram table does not contain puzzle rows") - } - - rowHints := make([][]int, height) - grid := make([][]string, height) - for y := range height { - row := dataRows[colHintRows+y] - hints := parseHintCells(row[:rowHintCols]) - if len(hints) == 0 { - hints = []int{0} - } - rowHints[y] = hints - - gridRow := make([]string, colCount) - for x := 0; x < colCount; x++ { - cell := strings.TrimSpace(row[rowHintCols+x]) - switch cell { - case "", ".": - gridRow[x] = " " - default: - gridRow[x] = cell - } - } - grid[y] = gridRow - } - - colHints := make([][]int, colCount) - for x := 0; x < colCount; x++ { - hints := make([]int, 0, colHintRows) - for r := 0; r < colHintRows; r++ { - if v, ok := parseHintValue(dataRows[r][rowHintCols+x]); ok { - hints = append(hints, v) - } - } - if len(hints) == 0 { - hints = []int{0} - } - colHints[x] = hints - } - - return &NonogramData{ - Width: colCount, - Height: height, - RowHints: rowHints, - ColHints: colHints, - Grid: grid, - }, nil -} - -func parseGridTableBody(bodyLines []string, path string, bodyStartLine int) (*GridTable, error) { - tableLines, tableLineNumbers := findFirstMarkdownTable(bodyLines, bodyStartLine) - if len(tableLines) == 0 { - return nil, nil - } - if len(tableLines) < 2 { - return nil, parseError(path, tableLineNumbers[0], "expected markdown table to include data rows") - } - - rows := make([][]string, 0, len(tableLines)) - for i, line := range tableLines { - cells := parseTableRow(line) - if len(cells) == 0 { - return nil, parseError(path, tableLineNumbers[i], "empty table row") - } - rows = append(rows, cells) - } - - hasHeaderRow := false - if len(rows) > 1 && isMarkdownSeparatorRow(rows[1]) { - hasHeaderRow = true - rows = append(rows[:1], rows[2:]...) - } - if len(rows) < 2 { - return nil, parseError(path, tableLineNumbers[0], "table must include header and data rows") - } - - width := 0 - for _, row := range rows { - if len(row) > width { - width = len(row) - } - } - for i := range rows { - if len(rows[i]) >= width { - continue - } - padded := make([]string, width) - copy(padded, rows[i]) - rows[i] = padded - } - - return &GridTable{ - Rows: rows, - HasHeaderRow: hasHeaderRow, - HasHeaderCol: detectHeaderColumn(rows, hasHeaderRow), - }, nil -} - -func findFirstMarkdownTable(lines []string, startLine int) ([]string, []int) { - table := []string{} - lineNumbers := []int{} - started := false - - for i, line := range lines { - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "|") { - started = true - table = append(table, line) - lineNumbers = append(lineNumbers, startLine+i) - continue - } - if started { - break - } - } - - return table, lineNumbers -} - -func parseTableRow(line string) []string { - trimmed := strings.TrimSpace(line) - trimmed = strings.TrimPrefix(trimmed, "|") - trimmed = strings.TrimSuffix(trimmed, "|") - if trimmed == "" { - return []string{} - } - - parts := strings.Split(trimmed, "|") - cells := make([]string, 0, len(parts)) - for _, part := range parts { - cells = append(cells, strings.TrimSpace(part)) - } - return cells -} - -func isMarkdownSeparatorRow(cells []string) bool { - if len(cells) == 0 { - return false - } - for _, cell := range cells { - if !tableSepCellRegex.MatchString(strings.TrimSpace(cell)) { - return false - } - } - return true -} - -func rowHintPlaceholderRow(cells []string) bool { - for _, cell := range cells { - trimmed := strings.TrimSpace(cell) - if trimmed != "" && trimmed != "." { - return false - } - } - return true -} - -func parseHintCells(cells []string) []int { - hints := make([]int, 0, len(cells)) - for _, cell := range cells { - if v, ok := parseHintValue(cell); ok { - hints = append(hints, v) - } - } - return hints -} - -func detectHeaderColumn(rows [][]string, hasHeaderRow bool) bool { - if len(rows) < 2 { - return false - } - - start := 1 - if !hasHeaderRow { - start = 0 - } - - total := 0 - numeric := 0 - for i := start; i < len(rows); i++ { - if len(rows[i]) == 0 { - continue - } - total++ - if _, err := strconv.Atoi(strings.TrimSpace(rows[i][0])); err == nil { - numeric++ - } - } - - return total > 0 && numeric*100/total >= 70 -} - -func parseHintValue(cell string) (int, bool) { - trimmed := strings.TrimSpace(cell) - if trimmed == "" || trimmed == "." { - return 0, false - } - v, err := strconv.Atoi(trimmed) - if err != nil { - return 0, false - } - return v, true -} - -func trimSectionBody(lines *[]string) { - for len(*lines) > 0 { - trimmed := strings.TrimSpace((*lines)[0]) - if trimmed != "" && trimmed != "---" { - break - } - *lines = (*lines)[1:] - } - for len(*lines) > 0 { - trimmed := strings.TrimSpace((*lines)[len(*lines)-1]) - if trimmed != "" && trimmed != "---" { - break - } - *lines = (*lines)[:len(*lines)-1] - } -} - -func firstNonEmptyLine(lines []string) int { - for i, line := range lines { - if strings.TrimSpace(line) != "" { - return i - } - } - return -1 -} - -func findHeadingLines(lines []string) []int { - indexes := []int{} - for i, line := range lines { - if strings.HasPrefix(strings.TrimSpace(line), "## ") { - indexes = append(indexes, i) - } - } - return indexes -} - -func parseError(path string, line int, format string, args ...any) error { - if line < 1 { - line = 1 - } - return fmt.Errorf("%s:%d: %s", path, line, fmt.Sprintf(format, args...)) -} diff --git a/pdfexport/parse_fillomino.go b/pdfexport/parse_fillomino.go new file mode 100644 index 0000000..7b09cea --- /dev/null +++ b/pdfexport/parse_fillomino.go @@ -0,0 +1,50 @@ +package pdfexport + +import ( + "encoding/json" + "fmt" + "strings" +) + +type fillominoSave struct { + Width int `json:"width"` + Height int `json:"height"` + State string `json:"state"` + Provided string `json:"provided"` +} + +func ParseFillominoPrintData(saveData []byte) (*FillominoData, error) { + if len(strings.TrimSpace(string(saveData))) == 0 { + return nil, nil + } + + var save fillominoSave + if err := json.Unmarshal(saveData, &save); err != nil { + return nil, fmt.Errorf("decode fillomino save: %w", err) + } + if save.Width <= 0 || save.Height <= 0 { + return nil, nil + } + + state, err := parseNumberGrid(save.State, save.Width, save.Height) + if err != nil { + return nil, err + } + provided := parseProvidedMask(save.Provided, save.Width, save.Height) + + givens := make([][]int, save.Height) + for y := 0; y < save.Height; y++ { + givens[y] = make([]int, save.Width) + for x := 0; x < save.Width; x++ { + if provided[y][x] { + givens[y][x] = state[y][x] + } + } + } + + return &FillominoData{ + Width: save.Width, + Height: save.Height, + Givens: givens, + }, nil +} diff --git a/pdfexport/parse_hashi.go b/pdfexport/parse_hashi.go new file mode 100644 index 0000000..cee00e7 --- /dev/null +++ b/pdfexport/parse_hashi.go @@ -0,0 +1,50 @@ +package pdfexport + +import ( + "encoding/json" + "fmt" + "strings" +) + +type hashiSave struct { + Width int `json:"width"` + Height int `json:"height"` + Islands []hashiIsland `json:"islands"` +} + +type hashiIsland struct { + X int `json:"x"` + Y int `json:"y"` + Required int `json:"required"` +} + +func ParseHashiPrintData(saveData []byte) (*HashiData, error) { + if len(strings.TrimSpace(string(saveData))) == 0 { + return nil, nil + } + + var save hashiSave + if err := json.Unmarshal(saveData, &save); err != nil { + return nil, fmt.Errorf("decode hashiwokakero save: %w", err) + } + if save.Width <= 0 || save.Height <= 0 { + return nil, nil + } + + islands := make([]HashiIsland, 0, len(save.Islands)) + for _, island := range save.Islands { + if island.X < 0 || island.X >= save.Width || island.Y < 0 || island.Y >= save.Height { + continue + } + if island.Required <= 0 { + continue + } + islands = append(islands, HashiIsland(island)) + } + + return &HashiData{ + Width: save.Width, + Height: save.Height, + Islands: islands, + }, nil +} diff --git a/pdfexport/parse_helpers.go b/pdfexport/parse_helpers.go new file mode 100644 index 0000000..ae0758a --- /dev/null +++ b/pdfexport/parse_helpers.go @@ -0,0 +1,68 @@ +package pdfexport + +import ( + "fmt" + "strconv" + "strings" +) + +func splitNormalizedLines(raw string) []string { + normalized := strings.ReplaceAll(strings.ReplaceAll(raw, "\r\n", "\n"), "\r", "\n") + if strings.TrimSpace(normalized) == "" { + return nil + } + return strings.Split(normalized, "\n") +} + +func parseNumberGrid(encoded string, width, height int) ([][]int, error) { + rows := strings.Split(strings.TrimSpace(encoded), "\n") + grid := make([][]int, height) + for y := range height { + grid[y] = make([]int, width) + if y >= len(rows) { + continue + } + + fields := strings.Fields(rows[y]) + if len(fields) == 0 { + fields = splitCompactFields(rows[y]) + } + if len(fields) != width { + return nil, fmt.Errorf("decode number grid: invalid row width") + } + for x := range width { + if fields[x] == "." { + continue + } + value, err := strconv.Atoi(fields[x]) + if err != nil { + return nil, fmt.Errorf("decode number grid: %w", err) + } + grid[y][x] = value + } + } + return grid, nil +} + +func parseProvidedMask(encoded string, width, height int) [][]bool { + rows := strings.Split(encoded, "\n") + mask := make([][]bool, height) + for y := range height { + mask[y] = make([]bool, width) + if y >= len(rows) { + continue + } + for x := 0; x < width && x < len(rows[y]); x++ { + mask[y][x] = rows[y][x] == '#' + } + } + return mask +} + +func splitCompactFields(row string) []string { + fields := make([]string, 0, len(row)) + for _, r := range row { + fields = append(fields, string(r)) + } + return fields +} diff --git a/pdfexport/parse_hitori.go b/pdfexport/parse_hitori.go new file mode 100644 index 0000000..0df1640 --- /dev/null +++ b/pdfexport/parse_hitori.go @@ -0,0 +1,103 @@ +package pdfexport + +import ( + "encoding/json" + "fmt" + "strings" + "unicode/utf8" +) + +type hitoriSave struct { + Size int `json:"size"` + Numbers string `json:"numbers"` +} + +func ParseHitoriPrintData(saveData []byte) (*HitoriData, error) { + if len(strings.TrimSpace(string(saveData))) == 0 { + return nil, nil + } + + var save hitoriSave + if err := json.Unmarshal(saveData, &save); err != nil { + return nil, fmt.Errorf("decode hitori save: %w", err) + } + + rows := splitNormalizedLines(save.Numbers) + size := save.Size + if size <= 0 { + size = len(rows) + } + if size <= 0 { + return nil, nil + } + + numbers := make([][]string, size) + for y := 0; y < size; y++ { + numbers[y] = make([]string, size) + if y >= len(rows) { + continue + } + + rowValues := parseHitoriRowValues(rows[y]) + for x := 0; x < size && x < len(rowValues); x++ { + numbers[y][x] = rowValues[x] + } + } + + return &HitoriData{ + Size: size, + Numbers: numbers, + }, nil +} + +func parseHitoriRowValues(row string) []string { + row = strings.TrimSpace(row) + if row == "" { + return nil + } + + if strings.Contains(row, " ") || strings.Contains(row, ",") { + fields := strings.Fields(strings.ReplaceAll(row, ",", " ")) + if len(fields) > 1 { + values := make([]string, len(fields)) + for i, field := range fields { + values[i] = normalizeHitoriToken(field) + } + return values + } + } + + runes := []rune(row) + values := make([]string, len(runes)) + for i, r := range runes { + values[i] = normalizeHitoriRune(r) + } + return values +} + +func normalizeHitoriToken(token string) string { + token = strings.TrimSpace(token) + if token == "" || token == "." { + return "" + } + if utf8.RuneCountInString(token) == 1 { + r, _ := utf8.DecodeRuneInString(token) + return normalizeHitoriRune(r) + } + return token +} + +func normalizeHitoriRune(r rune) string { + switch { + case r == '.': + return "" + case r >= '0' && r <= '9': + return string(r) + default: + value := int(r - '0') + if value >= 10 && value <= 35 { + return fmt.Sprintf("%d", value) + } + return string(r) + } +} diff --git a/pdfexport/parse_nonogram.go b/pdfexport/parse_nonogram.go new file mode 100644 index 0000000..3ea73b6 --- /dev/null +++ b/pdfexport/parse_nonogram.go @@ -0,0 +1,129 @@ +package pdfexport + +import ( + "encoding/json" + "fmt" + "strings" +) + +type nonogramSave struct { + State string `json:"state"` + Width int `json:"width"` + Height int `json:"height"` + RowHints [][]int `json:"row-hints"` + ColHints [][]int `json:"col-hints"` +} + +func ParseNonogramPrintData(saveData []byte) (*NonogramData, error) { + if len(strings.TrimSpace(string(saveData))) == 0 { + return nil, nil + } + + var save nonogramSave + if err := json.Unmarshal(saveData, &save); err != nil { + return nil, fmt.Errorf("decode nonogram save: %w", err) + } + + stateRows := splitNonogramStateRows(save.State) + + width := save.Width + if width <= 0 { + width = len(save.ColHints) + } + if width <= 0 { + width = maxRuneWidth(stateRows) + } + + height := save.Height + if height <= 0 { + height = len(save.RowHints) + } + if height <= 0 { + height = len(stateRows) + } + + if width <= 0 || height <= 0 { + return nil, nil + } + + return &NonogramData{ + Width: width, + Height: height, + RowHints: normalizeNonogramHintRows(save.RowHints, height), + ColHints: normalizeNonogramHintRows(save.ColHints, width), + Grid: normalizeNonogramStateGrid(stateRows, width, height), + }, nil +} + +func splitNonogramStateRows(raw string) []string { + normalized := strings.ReplaceAll(strings.ReplaceAll(raw, "\r\n", "\n"), "\r", "\n") + if normalized == "" { + return nil + } + return strings.Split(normalized, "\n") +} + +func maxRuneWidth(rows []string) int { + maxWidth := 0 + for _, row := range rows { + if n := len([]rune(row)); n > maxWidth { + maxWidth = n + } + } + return maxWidth +} + +func normalizeNonogramHintRows(src [][]int, size int) [][]int { + if size <= 0 { + return nil + } + + normalized := make([][]int, size) + for i := range size { + if i >= len(src) { + normalized[i] = []int{0} + continue + } + + filtered := make([]int, 0, len(src[i])) + for _, value := range src[i] { + if value > 0 { + filtered = append(filtered, value) + } + } + if len(filtered) == 0 { + filtered = []int{0} + } + normalized[i] = filtered + } + + return normalized +} + +func normalizeNonogramStateGrid(rows []string, width, height int) [][]string { + if width <= 0 || height <= 0 { + return nil + } + + grid := make([][]string, height) + for y := range height { + grid[y] = make([]string, width) + for x := range width { + grid[y][x] = " " + } + + if y >= len(rows) { + continue + } + + runes := []rune(rows[y]) + for x := 0; x < width && x < len(runes); x++ { + if runes[x] == ' ' { + continue + } + grid[y][x] = string(runes[x]) + } + } + + return grid +} diff --git a/pdfexport/parse_nurikabe.go b/pdfexport/parse_nurikabe.go new file mode 100644 index 0000000..8b918f9 --- /dev/null +++ b/pdfexport/parse_nurikabe.go @@ -0,0 +1,74 @@ +package pdfexport + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" +) + +type nurikabeSave struct { + Width int `json:"width"` + Height int `json:"height"` + Clues string `json:"clues"` +} + +func ParseNurikabePrintData(saveData []byte) (*NurikabeData, error) { + if len(strings.TrimSpace(string(saveData))) == 0 { + return nil, nil + } + + var save nurikabeSave + if err := json.Unmarshal(saveData, &save); err != nil { + return nil, fmt.Errorf("decode nurikabe save: %w", err) + } + + width := save.Width + height := save.Height + if width <= 0 || height <= 0 { + return nil, nil + } + + clues, err := parseNurikabeClues(save.Clues, width, height) + if err != nil { + return nil, err + } + + return &NurikabeData{ + Width: width, + Height: height, + Clues: clues, + }, nil +} + +func parseNurikabeClues(raw string, width, height int) ([][]int, error) { + if width <= 0 || height <= 0 { + return nil, fmt.Errorf("invalid clue dimensions: %dx%d", width, height) + } + + clues := make([][]int, height) + for y := range height { + clues[y] = make([]int, width) + } + + rows := splitNormalizedLines(raw) + for y := 0; y < len(rows) && y < height; y++ { + parts := strings.Split(rows[y], ",") + for x := 0; x < len(parts) && x < width; x++ { + token := strings.TrimSpace(parts[x]) + if token == "" { + continue + } + value, err := strconv.Atoi(token) + if err != nil { + return nil, fmt.Errorf("invalid clue value %q at (%d,%d): %w", token, x, y, err) + } + if value < 0 { + return nil, fmt.Errorf("negative clue value %d at (%d,%d)", value, x, y) + } + clues[y][x] = value + } + } + + return clues, nil +} diff --git a/pdfexport/parse_rippleeffect.go b/pdfexport/parse_rippleeffect.go new file mode 100644 index 0000000..602cfe6 --- /dev/null +++ b/pdfexport/parse_rippleeffect.go @@ -0,0 +1,40 @@ +package pdfexport + +import ( + "encoding/json" + "fmt" + "strings" +) + +type rippleEffectSave struct { + Width int `json:"width"` + Height int `json:"height"` + Givens string `json:"givens"` + Cages []RippleEffectCage `json:"cages"` +} + +func ParseRippleEffectPrintData(saveData []byte) (*RippleEffectData, error) { + if len(strings.TrimSpace(string(saveData))) == 0 { + return nil, nil + } + + var save rippleEffectSave + if err := json.Unmarshal(saveData, &save); err != nil { + return nil, fmt.Errorf("decode ripple effect save: %w", err) + } + if save.Width <= 0 || save.Height <= 0 { + return nil, nil + } + + givens, err := parseNumberGrid(save.Givens, save.Width, save.Height) + if err != nil { + return nil, err + } + + return &RippleEffectData{ + Width: save.Width, + Height: save.Height, + Givens: givens, + Cages: append([]RippleEffectCage(nil), save.Cages...), + }, nil +} diff --git a/pdfexport/parse_shikaku.go b/pdfexport/parse_shikaku.go new file mode 100644 index 0000000..622c65d --- /dev/null +++ b/pdfexport/parse_shikaku.go @@ -0,0 +1,54 @@ +package pdfexport + +import ( + "encoding/json" + "fmt" + "strings" +) + +type shikakuSave struct { + Width int `json:"width"` + Height int `json:"height"` + Clues []shikakuClue `json:"clues"` +} + +type shikakuClue struct { + X int `json:"x"` + Y int `json:"y"` + Value int `json:"value"` +} + +func ParseShikakuPrintData(saveData []byte) (*ShikakuData, error) { + if len(strings.TrimSpace(string(saveData))) == 0 { + return nil, nil + } + + var save shikakuSave + if err := json.Unmarshal(saveData, &save); err != nil { + return nil, fmt.Errorf("decode shikaku save: %w", err) + } + if save.Width <= 0 || save.Height <= 0 { + return nil, nil + } + + clues := make([][]int, save.Height) + for y := 0; y < save.Height; y++ { + clues[y] = make([]int, save.Width) + } + + for _, clue := range save.Clues { + if clue.X < 0 || clue.X >= save.Width || clue.Y < 0 || clue.Y >= save.Height { + continue + } + if clue.Value <= 0 { + continue + } + clues[clue.Y][clue.X] = clue.Value + } + + return &ShikakuData{ + Width: save.Width, + Height: save.Height, + Clues: clues, + }, nil +} diff --git a/pdfexport/parse_sudoku.go b/pdfexport/parse_sudoku.go new file mode 100644 index 0000000..135e022 --- /dev/null +++ b/pdfexport/parse_sudoku.go @@ -0,0 +1,69 @@ +package pdfexport + +import ( + "encoding/json" + "fmt" + "strings" +) + +type sudokuSave struct { + Provided []sudokuCell `json:"provided"` +} + +type sudokuCell struct { + X int `json:"x"` + Y int `json:"y"` + V int `json:"v"` +} + +func ParseSudokuPrintData(saveData []byte) (*SudokuData, error) { + if len(strings.TrimSpace(string(saveData))) == 0 { + return nil, nil + } + + var save sudokuSave + if err := json.Unmarshal(saveData, &save); err != nil { + return nil, fmt.Errorf("decode sudoku save: %w", err) + } + + var givens [9][9]int + for _, cell := range save.Provided { + if !isSudokuCellInBounds(cell.X, cell.Y) { + continue + } + if cell.V < 1 || cell.V > 9 { + continue + } + givens[cell.Y][cell.X] = cell.V + } + + return &SudokuData{Givens: givens}, nil +} + +func ParseSudokuRGBPrintData(saveData []byte) (*SudokuData, error) { + if len(strings.TrimSpace(string(saveData))) == 0 { + return nil, nil + } + + var save sudokuSave + if err := json.Unmarshal(saveData, &save); err != nil { + return nil, fmt.Errorf("decode sudoku rgb save: %w", err) + } + + var givens [9][9]int + for _, cell := range save.Provided { + if !isSudokuCellInBounds(cell.X, cell.Y) { + continue + } + if cell.V < 1 || cell.V > 3 { + continue + } + givens[cell.Y][cell.X] = cell.V + } + + return &SudokuData{Givens: givens}, nil +} + +func isSudokuCellInBounds(x, y int) bool { + return x >= 0 && x < 9 && y >= 0 && y < 9 +} diff --git a/pdfexport/parse_takuzu.go b/pdfexport/parse_takuzu.go new file mode 100644 index 0000000..39570b9 --- /dev/null +++ b/pdfexport/parse_takuzu.go @@ -0,0 +1,71 @@ +package pdfexport + +import ( + "encoding/json" + "fmt" + "strings" +) + +type takuzuSave struct { + Size int `json:"size"` + State string `json:"state"` + Provided string `json:"provided"` +} + +func ParseTakuzuPrintData(saveData []byte) (*TakuzuData, error) { + if len(strings.TrimSpace(string(saveData))) == 0 { + return nil, nil + } + + var save takuzuSave + if err := json.Unmarshal(saveData, &save); err != nil { + return nil, fmt.Errorf("decode takuzu save: %w", err) + } + + stateRows := splitNormalizedLines(save.State) + providedRows := splitNormalizedLines(save.Provided) + + size := save.Size + if size <= 0 { + size = max(len(stateRows), len(providedRows)) + } + if size <= 0 { + return nil, nil + } + + givens := make([][]string, size) + for y := 0; y < size; y++ { + givens[y] = make([]string, size) + + var stateRunes []rune + if y < len(stateRows) { + stateRunes = []rune(stateRows[y]) + } + + var providedRunes []rune + if y < len(providedRows) { + providedRunes = []rune(providedRows[y]) + } + + for x := 0; x < size; x++ { + if x >= len(providedRunes) || providedRunes[x] != '#' { + continue + } + if x >= len(stateRunes) { + continue + } + if stateRunes[x] != '0' && stateRunes[x] != '1' { + continue + } + givens[y][x] = string(stateRunes[x]) + } + } + + return &TakuzuData{ + Size: size, + Givens: givens, + HorizontalRelations: make([][]string, size), + VerticalRelations: make([][]string, max(size-1, 0)), + GroupEveryTwo: true, + }, nil +} diff --git a/pdfexport/parse_takuzuplus.go b/pdfexport/parse_takuzuplus.go new file mode 100644 index 0000000..ab52c6d --- /dev/null +++ b/pdfexport/parse_takuzuplus.go @@ -0,0 +1,94 @@ +package pdfexport + +import ( + "encoding/json" + "fmt" + "strings" +) + +type takuzuPlusSave struct { + Size int `json:"size"` + State string `json:"state"` + Provided string `json:"provided"` + HorizontalRelations string `json:"horizontal_relations"` + VerticalRelations string `json:"vertical_relations"` +} + +func ParseTakuzuPlusPrintData(saveData []byte) (*TakuzuData, error) { + if len(strings.TrimSpace(string(saveData))) == 0 { + return nil, nil + } + + var save takuzuPlusSave + if err := json.Unmarshal(saveData, &save); err != nil { + return nil, fmt.Errorf("decode takuzu+ save: %w", err) + } + + stateRows := splitNormalizedLines(save.State) + providedRows := splitNormalizedLines(save.Provided) + horizontalRows := splitNormalizedLines(save.HorizontalRelations) + verticalRows := splitNormalizedLines(save.VerticalRelations) + + size := save.Size + if size <= 0 { + size = max(max(len(stateRows), len(providedRows)), max(len(horizontalRows), len(verticalRows)+1)) + } + if size <= 0 { + return nil, nil + } + + givens := make([][]string, size) + horizontal := make([][]string, size) + vertical := make([][]string, max(size-1, 0)) + for y := 0; y < size; y++ { + givens[y] = make([]string, size) + horizontal[y] = make([]string, max(size-1, 0)) + + var stateRunes []rune + if y < len(stateRows) { + stateRunes = []rune(stateRows[y]) + } + var providedRunes []rune + if y < len(providedRows) { + providedRunes = []rune(providedRows[y]) + } + var relationRunes []rune + if y < len(horizontalRows) { + relationRunes = []rune(horizontalRows[y]) + } + + for x := 0; x < size; x++ { + if x < size-1 && x < len(relationRunes) && (relationRunes[x] == '=' || relationRunes[x] == 'x') { + horizontal[y][x] = string(relationRunes[x]) + } + if x >= len(providedRunes) || providedRunes[x] != '#' || x >= len(stateRunes) { + continue + } + if stateRunes[x] != '0' && stateRunes[x] != '1' { + continue + } + givens[y][x] = string(stateRunes[x]) + } + } + + for y := 0; y < size-1; y++ { + vertical[y] = make([]string, size) + if y >= len(verticalRows) { + continue + } + relationRunes := []rune(verticalRows[y]) + for x := 0; x < size && x < len(relationRunes); x++ { + if relationRunes[x] == '=' || relationRunes[x] == 'x' { + vertical[y][x] = string(relationRunes[x]) + } + } + } + + return &TakuzuData{ + Size: size, + Givens: givens, + HorizontalRelations: horizontal, + VerticalRelations: vertical, + GroupEveryTwo: true, + }, nil +} diff --git a/pdfexport/parse_test.go b/pdfexport/parse_test.go deleted file mode 100644 index b1451fa..0000000 --- a/pdfexport/parse_test.go +++ /dev/null @@ -1,134 +0,0 @@ -package pdfexport - -import ( - "os" - "path/filepath" - "strconv" - "testing" -) - -func TestParseMarkdownNonogram(t *testing.T) { - doc, err := ParseMarkdown("sample.md", sampleNonogramDoc("Standard", 1)) - if err != nil { - t.Fatal(err) - } - - if got, want := doc.Metadata.Category, "Nonogram"; got != want { - t.Fatalf("category = %q, want %q", got, want) - } - if got, want := len(doc.Puzzles), 1; got != want { - t.Fatalf("puzzles = %d, want %d", got, want) - } - - p := doc.Puzzles[0] - nonogram, ok := p.PrintPayload.(*NonogramData) - if !ok || nonogram == nil { - t.Fatal("expected parsed nonogram data") - } - if nonogram.Width != 2 || nonogram.Height != 2 { - t.Fatalf("nonogram size = %dx%d, want 2x2", nonogram.Width, nonogram.Height) - } - - if got, want := nonogram.RowHints[0][0], 1; got != want { - t.Fatalf("first row first hint = %d, want %d", got, want) - } - if got, want := nonogram.ColHints[0][0], 1; got != want { - t.Fatalf("first col first hint = %d, want %d", got, want) - } - - if got, want := nonogram.Grid[0][0], " "; got != want { - t.Fatalf("grid dot replacement = %q, want %q", got, want) - } -} - -func TestParseFilesMultipleInputs(t *testing.T) { - temp := t.TempDir() - - fileA := filepath.Join(temp, "pack-a.md") - if err := os.WriteFile(fileA, []byte(sampleNonogramDoc("Standard", 1)), 0o644); err != nil { - t.Fatal(err) - } - - fileB := filepath.Join(temp, "pack-b.md") - if err := os.WriteFile(fileB, []byte(sampleNonogramDoc("Classic", 2)), 0o644); err != nil { - t.Fatal(err) - } - - docs, err := ParseFiles([]string{fileA, fileB}) - if err != nil { - t.Fatal(err) - } - if got, want := len(docs), 2; got != want { - t.Fatalf("docs = %d, want %d", got, want) - } - - if got, want := docs[0].Metadata.SourceFileName, "pack-a.md"; got != want { - t.Fatalf("first source file = %q, want %q", got, want) - } - if got, want := docs[1].Puzzles[0].ModeSelection, "Classic"; got != want { - t.Fatalf("second mode selection = %q, want %q", got, want) - } -} - -func TestParseMarkdownTakuzuTable(t *testing.T) { - doc, err := ParseMarkdown("takuzu.md", sampleTakuzuDoc()) - if err != nil { - t.Fatal(err) - } - - if got, want := doc.Metadata.Category, "Takuzu"; got != want { - t.Fatalf("category = %q, want %q", got, want) - } - - p := doc.Puzzles[0] - table, ok := p.PrintPayload.(*GridTable) - if !ok || table == nil { - t.Fatal("expected parsed grid table for takuzu") - } - if !table.HasHeaderRow { - t.Fatal("expected takuzu table to detect a header row") - } - if !table.HasHeaderCol { - t.Fatal("expected takuzu table to detect a header column") - } - - if got, want := table.Rows[1][1], "."; got != want { - t.Fatalf("table cell = %q, want %q", got, want) - } -} - -func sampleNonogramDoc(mode string, idx int) string { - return "# PuzzleTea Export\n\n" + - "- Generated: 2026-02-21T20:42:05-07:00\n" + - "- Version: v1.6.0\n" + - "- Category: Nonogram\n" + - "- Mode Selection: " + mode + "\n" + - "- Count: 1\n" + - "- Seed: zine\n\n" + - "## ember-newt - " + strconv.Itoa(idx) + "\n\n" + - "### Puzzle Grid with Integrated Hints\n\n" + - "| R1 | R2 | C1 | C2 |\n" + - "| --- | --- | --- | --- |\n" + - "| . | . | 1 | 2 |\n" + - "| . | . | 3 | 4 |\n" + - "| 1 | 1 | . | . |\n" + - "| . | 2 | . | . |\n\n" + - "Row hints are right-aligned beside each row.\n" -} - -func sampleTakuzuDoc() string { - return "# PuzzleTea Export\n\n" + - "- Generated: 2026-02-21T20:42:05-07:00\n" + - "- Version: v1.6.0\n" + - "- Category: Takuzu\n" + - "- Mode Selection: Beginner\n" + - "- Count: 1\n" + - "- Seed: zine\n\n" + - "## scarlet-lichen - 1\n\n" + - "### Given Grid\n\n" + - "| | 1 | 2 | 3 |\n" + - "| --- | --- | --- | --- |\n" + - "| 1 | . | 0 | . |\n" + - "| 2 | 1 | . | 0 |\n\n" + - "Goal: fill with 0/1.\n" -} diff --git a/pdfexport/parse_wordsearch.go b/pdfexport/parse_wordsearch.go new file mode 100644 index 0000000..66fcdb0 --- /dev/null +++ b/pdfexport/parse_wordsearch.go @@ -0,0 +1,84 @@ +package pdfexport + +import ( + "encoding/json" + "fmt" + "strings" + "unicode" +) + +type wordSearchSave struct { + Width int `json:"width"` + Height int `json:"height"` + Grid string `json:"grid"` + Words []wordSearchWord `json:"words"` +} + +type wordSearchWord struct { + Text string `json:"text"` +} + +func ParseWordSearchPrintData(saveData []byte) (*WordSearchData, error) { + if len(strings.TrimSpace(string(saveData))) == 0 { + return nil, nil + } + + var save wordSearchSave + if err := json.Unmarshal(saveData, &save); err != nil { + return nil, fmt.Errorf("decode word search save: %w", err) + } + + rows := strings.Split(strings.ReplaceAll(strings.ReplaceAll(save.Grid, "\r\n", "\n"), "\r", "\n"), "\n") + if len(rows) == 1 && rows[0] == "" { + rows = nil + } + + width := save.Width + for _, row := range rows { + if n := len([]rune(row)); n > width { + width = n + } + } + + height := save.Height + height = max(height, len(rows)) + if width <= 0 || height <= 0 { + return nil, nil + } + + grid := make([][]string, height) + for y := 0; y < height; y++ { + grid[y] = make([]string, width) + runes := []rune{} + if y < len(rows) { + runes = []rune(rows[y]) + } + for x := 0; x < width; x++ { + grid[y][x] = " " + if x >= len(runes) { + continue + } + r := runes[x] + if unicode.IsSpace(r) { + continue + } + grid[y][x] = string(unicode.ToUpper(r)) + } + } + + words := make([]string, 0, len(save.Words)) + for _, word := range save.Words { + text := strings.ToUpper(strings.TrimSpace(word.Text)) + if text == "" { + continue + } + words = append(words, text) + } + + return &WordSearchData{ + Width: width, + Height: height, + Grid: grid, + Words: words, + }, nil +} diff --git a/pdfexport/print_adapter.go b/pdfexport/print_adapter.go index 7782c0b..d812a48 100644 --- a/pdfexport/print_adapter.go +++ b/pdfexport/print_adapter.go @@ -1,10 +1,12 @@ +// Package pdfexport owns the printable export pipeline: adapter registration, +// JSONL ingestion, payload building, ordering, and PDF rendering. package pdfexport import ( "reflect" - "strings" "codeberg.org/go-pdf/fpdf" + "github.com/FelineStateMachine/puzzletea/puzzle" ) type PrintAdapter interface { @@ -57,8 +59,5 @@ func IsNilPrintPayload(payload any) bool { } func normalizeGameTypeToken(s string) string { - s = strings.ToLower(strings.TrimSpace(s)) - s = strings.ReplaceAll(s, "-", " ") - s = strings.ReplaceAll(s, "_", " ") - return strings.Join(strings.Fields(s), " ") + return puzzle.NormalizeName(s) } diff --git a/pdfexport/printdata.go b/pdfexport/printdata.go deleted file mode 100644 index 743f0a2..0000000 --- a/pdfexport/printdata.go +++ /dev/null @@ -1,809 +0,0 @@ -package pdfexport - -import ( - "encoding/json" - "fmt" - "strconv" - "strings" - "unicode" - "unicode/utf8" -) - -type nurikabeSave struct { - Width int `json:"width"` - Height int `json:"height"` - Clues string `json:"clues"` -} - -type nonogramSave struct { - State string `json:"state"` - Width int `json:"width"` - Height int `json:"height"` - RowHints [][]int `json:"row-hints"` - ColHints [][]int `json:"col-hints"` -} - -type hashiSave struct { - Width int `json:"width"` - Height int `json:"height"` - Islands []hashiIsland `json:"islands"` -} - -type hashiIsland struct { - X int `json:"x"` - Y int `json:"y"` - Required int `json:"required"` -} - -type shikakuSave struct { - Width int `json:"width"` - Height int `json:"height"` - Clues []shikakuClue `json:"clues"` -} - -type shikakuClue struct { - X int `json:"x"` - Y int `json:"y"` - Value int `json:"value"` -} - -type hitoriSave struct { - Size int `json:"size"` - Numbers string `json:"numbers"` -} - -type takuzuSave struct { - Size int `json:"size"` - State string `json:"state"` - Provided string `json:"provided"` -} - -type takuzuPlusSave struct { - Size int `json:"size"` - State string `json:"state"` - Provided string `json:"provided"` - HorizontalRelations string `json:"horizontal_relations"` - VerticalRelations string `json:"vertical_relations"` -} - -type sudokuSave struct { - Provided []sudokuCell `json:"provided"` -} - -type fillominoSave struct { - Width int `json:"width"` - Height int `json:"height"` - State string `json:"state"` - Provided string `json:"provided"` -} - -type rippleEffectSave struct { - Width int `json:"width"` - Height int `json:"height"` - Givens string `json:"givens"` - Cages []RippleEffectCage `json:"cages"` -} - -type sudokuCell struct { - X int `json:"x"` - Y int `json:"y"` - V int `json:"v"` -} - -type wordSearchSave struct { - Width int `json:"width"` - Height int `json:"height"` - Grid string `json:"grid"` - Words []wordSearchWord `json:"words"` -} - -type wordSearchWord struct { - Text string `json:"text"` -} - -func ParseNonogramPrintData(saveData []byte) (*NonogramData, error) { - if len(strings.TrimSpace(string(saveData))) == 0 { - return nil, nil - } - - var save nonogramSave - if err := json.Unmarshal(saveData, &save); err != nil { - return nil, fmt.Errorf("decode nonogram save: %w", err) - } - - stateRows := splitNonogramStateRows(save.State) - - width := save.Width - if width <= 0 { - width = len(save.ColHints) - } - if width <= 0 { - width = maxRuneWidth(stateRows) - } - - height := save.Height - if height <= 0 { - height = len(save.RowHints) - } - if height <= 0 { - height = len(stateRows) - } - - if width <= 0 || height <= 0 { - return nil, nil - } - - return &NonogramData{ - Width: width, - Height: height, - RowHints: normalizeNonogramHintRows(save.RowHints, height), - ColHints: normalizeNonogramHintRows(save.ColHints, width), - Grid: normalizeNonogramStateGrid(stateRows, width, height), - }, nil -} - -func ParseNurikabePrintData(saveData []byte) (*NurikabeData, error) { - if len(strings.TrimSpace(string(saveData))) == 0 { - return nil, nil - } - - var save nurikabeSave - if err := json.Unmarshal(saveData, &save); err != nil { - return nil, fmt.Errorf("decode nurikabe save: %w", err) - } - - width := save.Width - height := save.Height - if width <= 0 || height <= 0 { - return nil, nil - } - - clues, err := parseNurikabeClues(save.Clues, width, height) - if err != nil { - return nil, err - } - - return &NurikabeData{ - Width: width, - Height: height, - Clues: clues, - }, nil -} - -func ParseShikakuPrintData(saveData []byte) (*ShikakuData, error) { - if len(strings.TrimSpace(string(saveData))) == 0 { - return nil, nil - } - - var save shikakuSave - if err := json.Unmarshal(saveData, &save); err != nil { - return nil, fmt.Errorf("decode shikaku save: %w", err) - } - if save.Width <= 0 || save.Height <= 0 { - return nil, nil - } - - clues := make([][]int, save.Height) - for y := 0; y < save.Height; y++ { - clues[y] = make([]int, save.Width) - } - - for _, clue := range save.Clues { - if clue.X < 0 || clue.X >= save.Width || clue.Y < 0 || clue.Y >= save.Height { - continue - } - if clue.Value <= 0 { - continue - } - clues[clue.Y][clue.X] = clue.Value - } - - return &ShikakuData{ - Width: save.Width, - Height: save.Height, - Clues: clues, - }, nil -} - -func ParseHashiPrintData(saveData []byte) (*HashiData, error) { - if len(strings.TrimSpace(string(saveData))) == 0 { - return nil, nil - } - - var save hashiSave - if err := json.Unmarshal(saveData, &save); err != nil { - return nil, fmt.Errorf("decode hashiwokakero save: %w", err) - } - if save.Width <= 0 || save.Height <= 0 { - return nil, nil - } - - islands := make([]HashiIsland, 0, len(save.Islands)) - for _, island := range save.Islands { - if island.X < 0 || island.X >= save.Width || island.Y < 0 || island.Y >= save.Height { - continue - } - if island.Required <= 0 { - continue - } - islands = append(islands, HashiIsland(island)) - } - - return &HashiData{ - Width: save.Width, - Height: save.Height, - Islands: islands, - }, nil -} - -func ParseHitoriPrintData(saveData []byte) (*HitoriData, error) { - if len(strings.TrimSpace(string(saveData))) == 0 { - return nil, nil - } - - var save hitoriSave - if err := json.Unmarshal(saveData, &save); err != nil { - return nil, fmt.Errorf("decode hitori save: %w", err) - } - - rows := splitNormalizedLines(save.Numbers) - size := save.Size - if size <= 0 { - size = len(rows) - } - if size <= 0 { - return nil, nil - } - - numbers := make([][]string, size) - for y := 0; y < size; y++ { - numbers[y] = make([]string, size) - if y >= len(rows) { - continue - } - - rowValues := parseHitoriRowValues(rows[y]) - for x := 0; x < size && x < len(rowValues); x++ { - numbers[y][x] = rowValues[x] - } - } - - return &HitoriData{ - Size: size, - Numbers: numbers, - }, nil -} - -func ParseTakuzuPrintData(saveData []byte) (*TakuzuData, error) { - if len(strings.TrimSpace(string(saveData))) == 0 { - return nil, nil - } - - var save takuzuSave - if err := json.Unmarshal(saveData, &save); err != nil { - return nil, fmt.Errorf("decode takuzu save: %w", err) - } - - stateRows := splitNormalizedLines(save.State) - providedRows := splitNormalizedLines(save.Provided) - - size := save.Size - if size <= 0 { - size = max(len(stateRows), len(providedRows)) - } - if size <= 0 { - return nil, nil - } - - givens := make([][]string, size) - for y := 0; y < size; y++ { - givens[y] = make([]string, size) - - var stateRunes []rune - if y < len(stateRows) { - stateRunes = []rune(stateRows[y]) - } - - var providedRunes []rune - if y < len(providedRows) { - providedRunes = []rune(providedRows[y]) - } - - for x := 0; x < size; x++ { - if x >= len(providedRunes) || providedRunes[x] != '#' { - continue - } - if x >= len(stateRunes) { - continue - } - if stateRunes[x] != '0' && stateRunes[x] != '1' { - continue - } - givens[y][x] = string(stateRunes[x]) - } - } - - return &TakuzuData{ - Size: size, - Givens: givens, - HorizontalRelations: make([][]string, size), - VerticalRelations: make([][]string, max(size-1, 0)), - GroupEveryTwo: true, - }, nil -} - -func ParseTakuzuPlusPrintData(saveData []byte) (*TakuzuData, error) { - if len(strings.TrimSpace(string(saveData))) == 0 { - return nil, nil - } - - var save takuzuPlusSave - if err := json.Unmarshal(saveData, &save); err != nil { - return nil, fmt.Errorf("decode takuzu+ save: %w", err) - } - - stateRows := splitNormalizedLines(save.State) - providedRows := splitNormalizedLines(save.Provided) - horizontalRows := splitNormalizedLines(save.HorizontalRelations) - verticalRows := splitNormalizedLines(save.VerticalRelations) - - size := save.Size - if size <= 0 { - size = max(max(len(stateRows), len(providedRows)), max(len(horizontalRows), len(verticalRows)+1)) - } - if size <= 0 { - return nil, nil - } - - givens := make([][]string, size) - horizontal := make([][]string, size) - vertical := make([][]string, max(size-1, 0)) - for y := 0; y < size; y++ { - givens[y] = make([]string, size) - horizontal[y] = make([]string, max(size-1, 0)) - - var stateRunes []rune - if y < len(stateRows) { - stateRunes = []rune(stateRows[y]) - } - var providedRunes []rune - if y < len(providedRows) { - providedRunes = []rune(providedRows[y]) - } - var relationRunes []rune - if y < len(horizontalRows) { - relationRunes = []rune(horizontalRows[y]) - } - - for x := 0; x < size; x++ { - if x < size-1 && x < len(relationRunes) && (relationRunes[x] == '=' || relationRunes[x] == 'x') { - horizontal[y][x] = string(relationRunes[x]) - } - if x >= len(providedRunes) || providedRunes[x] != '#' || x >= len(stateRunes) { - continue - } - if stateRunes[x] != '0' && stateRunes[x] != '1' { - continue - } - givens[y][x] = string(stateRunes[x]) - } - } - - for y := 0; y < size-1; y++ { - vertical[y] = make([]string, size) - if y >= len(verticalRows) { - continue - } - relationRunes := []rune(verticalRows[y]) - for x := 0; x < size && x < len(relationRunes); x++ { - if relationRunes[x] == '=' || relationRunes[x] == 'x' { - vertical[y][x] = string(relationRunes[x]) - } - } - } - - return &TakuzuData{ - Size: size, - Givens: givens, - HorizontalRelations: horizontal, - VerticalRelations: vertical, - GroupEveryTwo: true, - }, nil -} - -func ParseSudokuPrintData(saveData []byte) (*SudokuData, error) { - if len(strings.TrimSpace(string(saveData))) == 0 { - return nil, nil - } - - var save sudokuSave - if err := json.Unmarshal(saveData, &save); err != nil { - return nil, fmt.Errorf("decode sudoku save: %w", err) - } - - var givens [9][9]int - for _, cell := range save.Provided { - if !isSudokuCellInBounds(cell.X, cell.Y) { - continue - } - if cell.V < 1 || cell.V > 9 { - continue - } - givens[cell.Y][cell.X] = cell.V - } - - return &SudokuData{Givens: givens}, nil -} - -func ParseSudokuRGBPrintData(saveData []byte) (*SudokuData, error) { - if len(strings.TrimSpace(string(saveData))) == 0 { - return nil, nil - } - - var save sudokuSave - if err := json.Unmarshal(saveData, &save); err != nil { - return nil, fmt.Errorf("decode sudoku rgb save: %w", err) - } - - var givens [9][9]int - for _, cell := range save.Provided { - if !isSudokuCellInBounds(cell.X, cell.Y) { - continue - } - if cell.V < 1 || cell.V > 3 { - continue - } - givens[cell.Y][cell.X] = cell.V - } - - return &SudokuData{Givens: givens}, nil -} - -func ParseFillominoPrintData(saveData []byte) (*FillominoData, error) { - if len(strings.TrimSpace(string(saveData))) == 0 { - return nil, nil - } - - var save fillominoSave - if err := json.Unmarshal(saveData, &save); err != nil { - return nil, fmt.Errorf("decode fillomino save: %w", err) - } - if save.Width <= 0 || save.Height <= 0 { - return nil, nil - } - - state, err := parseNumberGrid(save.State, save.Width, save.Height) - if err != nil { - return nil, err - } - provided := parseProvidedMask(save.Provided, save.Width, save.Height) - - givens := make([][]int, save.Height) - for y := 0; y < save.Height; y++ { - givens[y] = make([]int, save.Width) - for x := 0; x < save.Width; x++ { - if provided[y][x] { - givens[y][x] = state[y][x] - } - } - } - - return &FillominoData{ - Width: save.Width, - Height: save.Height, - Givens: givens, - }, nil -} - -func ParseRippleEffectPrintData(saveData []byte) (*RippleEffectData, error) { - if len(strings.TrimSpace(string(saveData))) == 0 { - return nil, nil - } - - var save rippleEffectSave - if err := json.Unmarshal(saveData, &save); err != nil { - return nil, fmt.Errorf("decode ripple effect save: %w", err) - } - if save.Width <= 0 || save.Height <= 0 { - return nil, nil - } - - givens, err := parseNumberGrid(save.Givens, save.Width, save.Height) - if err != nil { - return nil, err - } - - return &RippleEffectData{ - Width: save.Width, - Height: save.Height, - Givens: givens, - Cages: append([]RippleEffectCage(nil), save.Cages...), - }, nil -} - -func ParseWordSearchPrintData(saveData []byte) (*WordSearchData, error) { - if len(strings.TrimSpace(string(saveData))) == 0 { - return nil, nil - } - - var save wordSearchSave - if err := json.Unmarshal(saveData, &save); err != nil { - return nil, fmt.Errorf("decode word search save: %w", err) - } - - rows := strings.Split(strings.ReplaceAll(strings.ReplaceAll(save.Grid, "\r\n", "\n"), "\r", "\n"), "\n") - if len(rows) == 1 && rows[0] == "" { - rows = nil - } - - width := save.Width - for _, row := range rows { - if n := len([]rune(row)); n > width { - width = n - } - } - - height := save.Height - height = max(height, len(rows)) - if width <= 0 || height <= 0 { - return nil, nil - } - - grid := make([][]string, height) - for y := 0; y < height; y++ { - grid[y] = make([]string, width) - runes := []rune{} - if y < len(rows) { - runes = []rune(rows[y]) - } - for x := 0; x < width; x++ { - grid[y][x] = " " - if x >= len(runes) { - continue - } - r := runes[x] - if unicode.IsSpace(r) { - continue - } - grid[y][x] = string(unicode.ToUpper(r)) - } - } - - words := make([]string, 0, len(save.Words)) - for _, word := range save.Words { - text := strings.ToUpper(strings.TrimSpace(word.Text)) - if text == "" { - continue - } - words = append(words, text) - } - - return &WordSearchData{ - Width: width, - Height: height, - Grid: grid, - Words: words, - }, nil -} - -func parseNumberGrid(encoded string, width, height int) ([][]int, error) { - rows := strings.Split(strings.TrimSpace(encoded), "\n") - grid := make([][]int, height) - for y := range height { - grid[y] = make([]int, width) - if y >= len(rows) { - continue - } - - fields := strings.Fields(rows[y]) - if len(fields) == 0 { - fields = splitCompactFields(rows[y]) - } - if len(fields) != width { - return nil, fmt.Errorf("decode number grid: invalid row width") - } - for x := range width { - if fields[x] == "." { - continue - } - value, err := strconv.Atoi(fields[x]) - if err != nil { - return nil, fmt.Errorf("decode number grid: %w", err) - } - grid[y][x] = value - } - } - return grid, nil -} - -func parseProvidedMask(encoded string, width, height int) [][]bool { - rows := strings.Split(encoded, "\n") - mask := make([][]bool, height) - for y := range height { - mask[y] = make([]bool, width) - if y >= len(rows) { - continue - } - for x := 0; x < width && x < len(rows[y]); x++ { - mask[y][x] = rows[y][x] == '#' - } - } - return mask -} - -func splitCompactFields(row string) []string { - fields := make([]string, 0, len(row)) - for _, r := range row { - fields = append(fields, string(r)) - } - return fields -} - -func isSudokuCellInBounds(x, y int) bool { - return x >= 0 && x < 9 && y >= 0 && y < 9 -} - -func splitNormalizedLines(raw string) []string { - normalized := strings.ReplaceAll(strings.ReplaceAll(raw, "\r\n", "\n"), "\r", "\n") - if strings.TrimSpace(normalized) == "" { - return nil - } - return strings.Split(normalized, "\n") -} - -func parseHitoriRowValues(row string) []string { - row = strings.TrimSpace(row) - if row == "" { - return nil - } - - if strings.Contains(row, " ") || strings.Contains(row, ",") { - fields := strings.Fields(strings.ReplaceAll(row, ",", " ")) - if len(fields) > 1 { - values := make([]string, len(fields)) - for i, field := range fields { - values[i] = normalizeHitoriToken(field) - } - return values - } - } - - runes := []rune(row) - values := make([]string, len(runes)) - for i, r := range runes { - values[i] = normalizeHitoriRune(r) - } - return values -} - -func normalizeHitoriToken(token string) string { - token = strings.TrimSpace(token) - if token == "" || token == "." { - return "" - } - if utf8.RuneCountInString(token) == 1 { - r, _ := utf8.DecodeRuneInString(token) - return normalizeHitoriRune(r) - } - return token -} - -func normalizeHitoriRune(r rune) string { - switch { - case r == '.': - return "" - case r >= '0' && r <= '9': - return string(r) - default: - value := int(r - '0') - if value >= 10 && value <= 35 { - return fmt.Sprintf("%d", value) - } - return string(r) - } -} - -func parseNurikabeClues(raw string, width, height int) ([][]int, error) { - if width <= 0 || height <= 0 { - return nil, fmt.Errorf("invalid clue dimensions: %dx%d", width, height) - } - - clues := make([][]int, height) - for y := range height { - clues[y] = make([]int, width) - } - - rows := splitNormalizedLines(raw) - for y := 0; y < len(rows) && y < height; y++ { - parts := strings.Split(rows[y], ",") - for x := 0; x < len(parts) && x < width; x++ { - token := strings.TrimSpace(parts[x]) - if token == "" { - continue - } - value, err := strconv.Atoi(token) - if err != nil { - return nil, fmt.Errorf("invalid clue value %q at (%d,%d): %w", token, x, y, err) - } - if value < 0 { - return nil, fmt.Errorf("negative clue value %d at (%d,%d)", value, x, y) - } - clues[y][x] = value - } - } - - return clues, nil -} - -func splitNonogramStateRows(raw string) []string { - normalized := strings.ReplaceAll(strings.ReplaceAll(raw, "\r\n", "\n"), "\r", "\n") - if normalized == "" { - return nil - } - return strings.Split(normalized, "\n") -} - -func maxRuneWidth(rows []string) int { - maxWidth := 0 - for _, row := range rows { - if n := len([]rune(row)); n > maxWidth { - maxWidth = n - } - } - return maxWidth -} - -func normalizeNonogramHintRows(src [][]int, size int) [][]int { - if size <= 0 { - return nil - } - - normalized := make([][]int, size) - for i := range size { - if i >= len(src) { - normalized[i] = []int{0} - continue - } - - filtered := make([]int, 0, len(src[i])) - for _, value := range src[i] { - if value > 0 { - filtered = append(filtered, value) - } - } - if len(filtered) == 0 { - filtered = []int{0} - } - normalized[i] = filtered - } - - return normalized -} - -func normalizeNonogramStateGrid(rows []string, width, height int) [][]string { - if width <= 0 || height <= 0 { - return nil - } - - grid := make([][]string, height) - for y := range height { - grid[y] = make([]string, width) - for x := range width { - grid[y][x] = " " - } - - if y >= len(rows) { - continue - } - - runes := []rune(rows[y]) - for x := 0; x < width && x < len(runes); x++ { - if runes[x] == ' ' { - continue - } - grid[y][x] = string(runes[x]) - } - } - - return grid -} diff --git a/pdfexport/render_cover.go b/pdfexport/render_cover.go index 0190c47..5facd29 100644 --- a/pdfexport/render_cover.go +++ b/pdfexport/render_cover.go @@ -23,11 +23,12 @@ func splitCoverTextLines(pdf *fpdf.Fpdf, text string, maxW float64) []string { return lines } -func splitCoverSubtitleLines(pdf *fpdf.Fpdf, subtitle string, maxW float64, maxLines int) []string { +func splitClampedTextLines(pdf *fpdf.Fpdf, text string, maxW float64, maxLines int) []string { if maxLines < 1 { maxLines = 1 } - lines := splitCoverTextLines(pdf, subtitle, maxW) + + lines := splitCoverTextLines(pdf, text, maxW) if len(lines) <= maxLines { return lines } @@ -38,6 +39,10 @@ func splitCoverSubtitleLines(pdf *fpdf.Fpdf, subtitle string, maxW float64, maxL return out } +func splitCoverSubtitleLines(pdf *fpdf.Fpdf, subtitle string, maxW float64, maxLines int) []string { + return splitClampedTextLines(pdf, subtitle, maxW, maxLines) +} + func renderCoverPage(pdf *fpdf.Fpdf, _ []Puzzle, cfg RenderConfig, coverColor RGB) { ink := RGB{R: 8, G: 8, B: 8} @@ -50,9 +55,6 @@ func renderCoverPage(pdf *fpdf.Fpdf, _ []Puzzle, cfg RenderConfig, coverColor RG frameInset := 7.5 drawCoverFrame(pdf, frameInset, pageW, pageH, ink) - scene := rectMM{x: frameInset + 4.0, y: frameInset + 10.0, w: pageW - (frameInset+4.0)*2, h: 132.0} - drawCoverArtwork(pdf, scene, cfg.ShuffleSeed, coverColor, ink) - subtitle := strings.TrimSpace(cfg.CoverSubtitle) if subtitle == "" { subtitle = "PuzzleTea Collection" @@ -76,7 +78,7 @@ func renderCoverPage(pdf *fpdf.Fpdf, _ []Puzzle, cfg RenderConfig, coverColor RG pdf.SetFont(coverFontFamily, "", fontSize) titleLines := splitCoverSubtitleLines(pdf, subtitle, labelW, 2) lineH := fontSize * 0.45 - y := scene.y + scene.h + 8.5 + y := frameInset + 12.0 for _, line := range titleLines { pdf.SetXY(frameInset+6.0, y) pdf.CellFormat(labelW, lineH, line, "", 0, "L", false, 0, "") @@ -98,18 +100,6 @@ func drawCoverFrame(pdf *fpdf.Fpdf, inset, pageW, pageH float64, ink RGB) { pdf.Rect(inner, inner, pageW-2*inner, pageH-2*inner, "D") } -func drawCoverArtwork(pdf *fpdf.Fpdf, scene rectMM, seed string, bg, ink RGB) { - drawCoverArtworkImage(pdf, scene, seed, "front", bg) - - pdf.SetDrawColor(int(ink.R), int(ink.G), int(ink.B)) - pdf.SetLineWidth(0.40) - pdf.Rect(scene.x, scene.y, scene.w, scene.h, "D") - - pdf.SetLineWidth(0.20) - inset := 1.8 - pdf.Rect(scene.x+inset, scene.y+inset, scene.w-2*inset, scene.h-2*inset, "D") -} - func renderBackCoverPage(pdf *fpdf.Fpdf, cfg RenderConfig, coverColor RGB) { ink := RGB{R: 8, G: 8, B: 8} @@ -121,9 +111,6 @@ func renderBackCoverPage(pdf *fpdf.Fpdf, cfg RenderConfig, coverColor RGB) { frameInset := 7.5 drawCoverFrame(pdf, frameInset, pageW, pageH, ink) - motif := rectMM{x: frameInset + 5.5, y: frameInset + 14.0, w: pageW - 2*(frameInset+5.5), h: 96.0} - drawBackMotif(pdf, motif, cfg.ShuffleSeed, coverColor, ink) - labelW := pageW - 2*(frameInset+6.0) pdf.SetTextColor(int(ink.R), int(ink.G), int(ink.B)) pdf.SetFont(sansFontFamily, "B", 8.4) @@ -131,18 +118,16 @@ func renderBackCoverPage(pdf *fpdf.Fpdf, cfg RenderConfig, coverColor RGB) { pdf.CellFormat(labelW, 4.2, "PuzzleTea", "", 0, "L", false, 0, "") pdf.SetFont(sansFontFamily, "", 8.0) - pdf.SetXY(frameInset+6.0, pageH-frameInset-17.0) - pdf.CellFormat(labelW, 4.2, cfg.AdvertText, "", 0, "L", false, 0, "") + advertLines := splitCoverTextLines(pdf, cfg.AdvertText, labelW) + advertLineH := 4.2 + advertY := pageH - frameInset - 17.0 - float64(len(advertLines)-1)*advertLineH + for _, line := range advertLines { + pdf.SetXY(frameInset+6.0, advertY) + pdf.CellFormat(labelW, advertLineH, line, "", 0, "L", false, 0, "") + advertY += advertLineH + } pdf.SetFont(sansFontFamily, "B", 8.2) pdf.SetXY(frameInset+6.0, pageH-frameInset-10.5) pdf.CellFormat(labelW, 4.2, fmt.Sprintf("VOL. %02d", cfg.VolumeNumber), "", 0, "L", false, 0, "") } - -func drawBackMotif(pdf *fpdf.Fpdf, scene rectMM, seed string, bg, ink RGB) { - drawCoverArtworkImage(pdf, scene, seed, "back", bg) - - pdf.SetDrawColor(int(ink.R), int(ink.G), int(ink.B)) - pdf.SetLineWidth(0.34) - pdf.Rect(scene.x, scene.y, scene.w, scene.h, "D") -} diff --git a/pdfexport/render_cover_test.go b/pdfexport/render_cover_test.go index 10114f2..7e44b8c 100644 --- a/pdfexport/render_cover_test.go +++ b/pdfexport/render_cover_test.go @@ -27,3 +27,24 @@ func TestSplitCoverSubtitleLinesClampsToMaxLines(t *testing.T) { t.Fatalf("splitCoverSubtitleLines not stable: %v vs %v", got, gotAgain) } } + +func TestSplitClampedTextLinesClampsToMaxLines(t *testing.T) { + pdf := fpdf.New("P", "mm", "A4", "") + if err := registerPDFFonts(pdf); err != nil { + t.Fatalf("registerPDFFonts error: %v", err) + } + pdf.AddPage() + pdf.SetFont(sansFontFamily, "", 10) + + text := "This is a deliberately long advert line that should wrap across multiple lines in the PDF renderer." + maxW := 55.0 + got := splitClampedTextLines(pdf, text, maxW, 2) + if len(got) != 2 { + t.Fatalf("line count = %d, want 2 (%v)", len(got), got) + } + + gotAgain := splitClampedTextLines(pdf, text, maxW, 2) + if !reflect.DeepEqual(got, gotAgain) { + t.Fatalf("splitClampedTextLines not stable: %v vs %v", got, gotAgain) + } +} diff --git a/pdfexport/render_title.go b/pdfexport/render_title.go index 35ce558..f8ff0d1 100644 --- a/pdfexport/render_title.go +++ b/pdfexport/render_title.go @@ -2,10 +2,9 @@ package pdfexport import ( "fmt" - "math" "sort" - "strconv" "strings" + "time" "codeberg.org/go-pdf/fpdf" ) @@ -15,6 +14,7 @@ func renderTitlePage(pdf *fpdf.Fpdf, docs []PackDocument, puzzles []Puzzle, cfg pageW, pageH := pdf.GetPageSize() margin := 12.0 contentWidth := pageW - 2*margin + categoryTotals := summarizeCategoryTotals(puzzles) pdf.SetTextColor(20, 20, 20) pdf.SetFont(sansFontFamily, "B", 22) @@ -26,114 +26,61 @@ func renderTitlePage(pdf *fpdf.Fpdf, docs []PackDocument, puzzles []Puzzle, cfg pdf.SetXY(0, 35) pdf.CellFormat(pageW, 8, cfg.CoverSubtitle, "", 0, "C", false, 0, "") - pdf.SetFont(sansFontFamily, "", 11) - pdf.SetTextColor(70, 70, 70) - pdf.SetXY(0, 44) - pdf.CellFormat(pageW, 6, "PuzzleTea Puzzle Pack", "", 0, "C", false, 0, "") - - versionLine := fmt.Sprintf("PuzzleTea Version: %s", strings.Join(summarizeVersions(docs), ", ")) - pdf.SetFont(sansFontFamily, "", 10) - wrappedVersions := pdf.SplitLines([]byte(versionLine), contentWidth) - if len(wrappedVersions) == 0 { - wrappedVersions = [][]byte{[]byte(versionLine)} - } - - headerLineH := 4.8 - headerGap := 1.2 - headerStartY := 54.8 - metaY := 56.0 + bodyY := 49.0 if header := strings.TrimSpace(cfg.HeaderText); header != "" { pdf.SetFont(sansFontFamily, "", 9.2) pdf.SetTextColor(74, 74, 74) - wrappedHeader := pdf.SplitLines([]byte(header), contentWidth) + wrappedHeader := pdf.SplitLines([]byte(header), contentWidth-20) if len(wrappedHeader) == 0 { wrappedHeader = [][]byte{[]byte(header)} } - - metaY = headerStartY + float64(len(wrappedHeader))*headerLineH + headerGap - sourceStartY := titlePageSourceTableStartY(metaY, len(wrappedVersions)) - sourceMaxY := pageH - 45 - if spare := titlePageSourceTableWhitespace(sourceMaxY, sourceStartY, len(docs)); spare > 0 { - headerGap += spare - } - - headerY := headerStartY for _, line := range wrappedHeader { - pdf.SetXY(margin, headerY) - pdf.CellFormat(contentWidth, headerLineH, string(line), "", 0, "C", false, 0, "") - headerY += headerLineH + pdf.SetXY(margin, bodyY) + pdf.CellFormat(contentWidth, 4.6, string(line), "", 0, "C", false, 0, "") + bodyY += 4.6 } - metaY = headerY + headerGap + bodyY += 2.0 } - pdf.SetTextColor(25, 25, 25) - pdf.SetFont(sansFontFamily, "", 10) - pdf.SetXY(margin, metaY) - pdf.CellFormat(contentWidth, 6, fmt.Sprintf("Generated: %s", cfg.GeneratedAt.Format("January 2, 2006")), "", 0, "L", false, 0, "") - metaY += 6 - for _, line := range wrappedVersions { - pdf.SetXY(margin, metaY) - pdf.CellFormat(contentWidth, 5.2, string(line), "", 0, "L", false, 0, "") - metaY += 5.2 + introLines := splitCoverTextLines(pdf, cfg.AdvertText, contentWidth) + if len(introLines) > 0 { + pdf.SetTextColor(58, 58, 58) + pdf.SetFont(sansFontFamily, "", 9.3) + for _, line := range introLines { + pdf.SetXY(margin, bodyY) + pdf.CellFormat(contentWidth, 4.8, line, "", 0, "C", false, 0, "") + bodyY += 4.8 + } + bodyY += 3.8 } - metaY += 0.8 - pdf.SetXY(margin, metaY) - pdf.CellFormat(contentWidth, 6, fmt.Sprintf("Puzzles: %d", len(puzzles)), "", 0, "L", false, 0, "") - metaY += 6 - metaY += 1.8 + pdf.SetTextColor(40, 40, 40) + pdf.SetFont(sansFontFamily, "B", 9.6) + pdf.SetXY(margin, bodyY) + pdf.CellFormat(contentWidth, 5.4, fmt.Sprintf("%d puzzles across %d categories", len(puzzles), len(categoryTotals)), "", 0, "C", false, 0, "") + bodyY += 7.6 pdf.SetFont(sansFontFamily, "B", 10) pdf.SetTextColor(45, 45, 45) - pdf.SetXY(margin, metaY) - pdf.CellFormat(contentWidth, 6, "Source Exports", "", 0, "L", false, 0, "") - metaY += 7 + pdf.SetXY(margin, bodyY) + pdf.CellFormat(contentWidth, 6, "Inside This Volume", "", 0, "C", false, 0, "") + bodyY += 7.0 - renderSourceExportsTable(pdf, docs, margin, metaY, contentWidth, pageH-45) + renderCategoryOverview(pdf, categoryTotals, margin, bodyY, contentWidth, pageH-32) pdf.SetTextColor(50, 50, 50) pdf.SetFont(sansFontFamily, "B", 12) - pdf.SetXY(margin, pageH-30) + footerTitleY := pageH - 31.0 + pdf.SetXY(margin, footerTitleY) pdf.CellFormat(contentWidth, 7, "Made with PuzzleTea", "", 0, "C", false, 0, "") - pdf.SetFont(sansFontFamily, "", 10) - pdf.SetXY(margin, pageH-22) - pdf.CellFormat(contentWidth, 6, cfg.AdvertText, "", 0, "C", false, 0, "") -} - -func titlePageSourceTableStartY(metaY float64, versionLineCount int) float64 { - y := metaY - y += 6 - y += float64(max(versionLineCount, 1)) * 5.2 - y += 0.8 - y += 6 - y += 1.8 - y += 7 - return y -} - -func titlePageSourceTableWhitespace(maxY, sourceStartY float64, docCount int) float64 { - const ( - headerHeight = 5.2 - rowHeight = 4.8 - ) - - availableRowsHeight := maxY - sourceStartY - headerHeight - if availableRowsHeight < rowHeight { - return 0 - } - - maxRows := int(math.Floor(availableRowsHeight / rowHeight)) - if maxRows < 1 { - return 0 + colophon := titlePageColophon(docs, cfg.GeneratedAt) + if colophon != "" { + pdf.SetFont(sansFontFamily, "", 7.8) + pdf.SetTextColor(112, 112, 112) + pdf.SetXY(margin, footerTitleY+8.0) + pdf.CellFormat(contentWidth, 4.0, colophon, "", 0, "C", false, 0, "") } - - rowCount := min(docCount, maxRows) - used := headerHeight + float64(rowCount)*rowHeight - if spare := maxY - sourceStartY - used; spare > 0 { - return spare - } - return 0 } func summarizeVersions(docs []PackDocument) []string { @@ -167,116 +114,101 @@ func defaultTitle(docs []PackDocument) string { return "PuzzleTea Mixed Puzzle Pack" } -func renderSourceExportsTable( - pdf *fpdf.Fpdf, - docs []PackDocument, - x, y, width, maxY float64, -) float64 { - if width <= 0 || y >= maxY { - return y - } +type namedCount struct { + Name string + Count int +} - headers := []string{"Source", "Category", "Mode", "Count", "Seed"} - columnRatios := []float64{0.33, 0.20, 0.22, 0.10, 0.15} - columnWidths := make([]float64, len(columnRatios)) - usedWidth := 0.0 - for i := 0; i < len(columnRatios)-1; i++ { - columnWidths[i] = width * columnRatios[i] - usedWidth += columnWidths[i] +func summarizeCategoryTotals(puzzles []Puzzle) []namedCount { + m := make(map[string]*namedCount) + for _, puzzle := range puzzles { + category := strings.TrimSpace(puzzle.Category) + if category == "" { + category = "Unknown" + } + key := normalizeToken(category) + if entry, ok := m[key]; ok { + entry.Count++ + continue + } + m[key] = &namedCount{Name: category, Count: 1} } - columnWidths[len(columnWidths)-1] = width - usedWidth + return sortedNamedCounts(m) +} - headerHeight := 5.2 - rowHeight := 4.8 - availableRowsHeight := maxY - y - headerHeight - if availableRowsHeight < rowHeight { - return y +func sortedNamedCounts(m map[string]*namedCount) []namedCount { + counts := make([]namedCount, 0, len(m)) + for _, entry := range m { + counts = append(counts, *entry) } - maxRows := int(math.Floor(availableRowsHeight / rowHeight)) - if maxRows < 1 { - return y - } - - rowCount := len(docs) - rowCount = min(rowCount, maxRows) + sort.SliceStable(counts, func(i, j int) bool { + return strings.Compare(normalizeToken(counts[i].Name), normalizeToken(counts[j].Name)) < 0 + }) + return counts +} - pdf.SetDrawColor(125, 125, 125) - pdf.SetLineWidth(thinGridLineMM) - pdf.SetFillColor(245, 245, 245) - pdf.SetTextColor(45, 45, 45) - pdf.SetFont(sansFontFamily, "B", 8.9) +func renderCategoryOverview( + pdf *fpdf.Fpdf, + categoryTotals []namedCount, + x, y, width, maxY float64, +) float64 { + const ( + lineHeight = 4.8 + categoryGap = 1.2 + columnGap = 6.0 + maxWidth = 96.0 + widthScale = 0.82 + ) - curX := x - for i, header := range headers { - pdf.SetXY(curX, y) - pdf.CellFormat(columnWidths[i], headerHeight, header, "1", 0, "C", true, 0, "") - curX += columnWidths[i] + if width <= 0 || y >= maxY || len(categoryTotals) == 0 { + return y } - pdf.SetFont(sansFontFamily, "", 8.6) pdf.SetTextColor(ruleTextGray, ruleTextGray, ruleTextGray) - for i := 0; i < rowCount; i++ { - rowY := y + headerHeight + float64(i)*rowHeight - mode := "" - if !isMixedModes(docs[i].Metadata.ModeSelection) { - mode = docs[i].Metadata.ModeSelection + pdf.SetFont(sansFontFamily, "", 9.0) + + containerWidth := min(width, min(maxWidth, width*widthScale)) + containerX := x + (width-containerWidth)/2 + colWidth := (containerWidth - columnGap) / 2 + leftCount := (len(categoryTotals) + 1) / 2 + maxRows := max(leftCount, len(categoryTotals)-leftCount) + curY := y + for row := 0; row < maxRows; row++ { + rowY := curY + float64(row)*(lineHeight+categoryGap) + if rowY+lineHeight > maxY { + return rowY } - - values := []string{ - docs[i].Metadata.SourceFileName, - docs[i].Metadata.Category, - mode, - strconv.Itoa(docs[i].Metadata.Count), - emptyAs(docs[i].Metadata.Seed, "none"), + if row < leftCount { + pdf.SetXY(containerX, rowY) + pdf.CellFormat(colWidth, lineHeight, formatCountLabel(categoryTotals[row]), "", 0, "R", false, 0, "") } - - curX = x - for col := range values { - cellText := fitTableCellText(pdf, values[col], columnWidths[col]-1.6) - align := "L" - if col == 3 { - align = "C" - } - pdf.SetXY(curX, rowY) - pdf.CellFormat(columnWidths[col], rowHeight, cellText, "1", 0, align, false, 0, "") - curX += columnWidths[col] + rightIndex := leftCount + row + if rightIndex < len(categoryTotals) { + pdf.SetXY(containerX+colWidth+columnGap, rowY) + pdf.CellFormat(colWidth, lineHeight, formatCountLabel(categoryTotals[rightIndex]), "", 0, "L", false, 0, "") } } - return y + headerHeight + float64(rowCount)*rowHeight + return curY + float64(maxRows)*(lineHeight+categoryGap) } -func fitTableCellText(pdf *fpdf.Fpdf, text string, maxWidth float64) string { - text = strings.TrimSpace(text) - if text == "" { - return "" - } - if maxWidth <= 0 { - return "" - } - if pdf.GetStringWidth(text) <= maxWidth { - return text - } +func formatCountLabel(item namedCount) string { + return fmt.Sprintf("%s x%d", item.Name, item.Count) +} - ellipsis := "..." - if pdf.GetStringWidth(ellipsis) > maxWidth { - return ellipsis +func titlePageColophon(docs []PackDocument, generatedAt time.Time) string { + parts := []string{} + versions := summarizeVersions(docs) + if len(versions) > 0 && versions[0] != "Unknown" { + parts = append(parts, fmt.Sprintf("PuzzleTea %s", strings.Join(versions, ", "))) } - runes := []rune(text) - for len(runes) > 0 { - candidate := string(runes) + ellipsis - if pdf.GetStringWidth(candidate) <= maxWidth { - return candidate - } - runes = runes[:len(runes)-1] + if !generatedAt.IsZero() { + parts = append(parts, generatedAt.Format("January 2, 2006")) } - return ellipsis -} -func emptyAs(v, fallback string) string { - if strings.TrimSpace(v) == "" { - return fallback + if len(parts) == 0 { + return "" } - return v + return strings.Join(parts, " | ") } diff --git a/pdfexport/render_title_test.go b/pdfexport/render_title_test.go new file mode 100644 index 0000000..644f94f --- /dev/null +++ b/pdfexport/render_title_test.go @@ -0,0 +1,51 @@ +package pdfexport + +import ( + "reflect" + "testing" + "time" +) + +func TestSummarizeCategoryTotalsAggregatesAcrossSources(t *testing.T) { + puzzles := []Puzzle{ + {SourceFileName: "sudoku-a.jsonl", Category: "Sudoku"}, + {SourceFileName: "sudoku-b.jsonl", Category: "Sudoku"}, + {SourceFileName: "takuzu-a.jsonl", Category: "Takuzu"}, + } + + got := summarizeCategoryTotals(puzzles) + want := []namedCount{ + {Name: "Sudoku", Count: 2}, + {Name: "Takuzu", Count: 1}, + } + + if !reflect.DeepEqual(got, want) { + t.Fatalf("summarizeCategoryTotals() = %+v, want %+v", got, want) + } +} + +func TestSummarizeCategoryTotalsUsesUnknownFallback(t *testing.T) { + puzzles := []Puzzle{ + {Category: ""}, + {Category: " "}, + } + + got := summarizeCategoryTotals(puzzles) + want := []namedCount{{Name: "Unknown", Count: 2}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("summarizeCategoryTotals() = %+v, want %+v", got, want) + } +} + +func TestTitlePageColophonIncludesVersionAndDate(t *testing.T) { + docs := []PackDocument{ + {Metadata: PackMetadata{Version: "v1.2.3"}}, + } + ts := time.Date(2026, 3, 10, 12, 0, 0, 0, time.UTC) + + got := titlePageColophon(docs, ts) + want := "PuzzleTea v1.2.3 | March 10, 2026" + if got != want { + t.Fatalf("titlePageColophon() = %q, want %q", got, want) + } +} diff --git a/puzzle/puzzle.go b/puzzle/puzzle.go index 551474a..ea8cad6 100644 --- a/puzzle/puzzle.go +++ b/puzzle/puzzle.go @@ -13,6 +13,13 @@ type ModeDef struct { Seeded bool } +type ModeSpec struct { + ID ModeID + Title string + Description string + Seeded bool +} + type Definition struct { ID GameID Name string @@ -22,6 +29,54 @@ type Definition struct { DailyModeIDs []ModeID } +type DefinitionSpec struct { + ID GameID + Name string + Description string + Aliases []string + Modes []ModeDef + DailyModeIDs []ModeID +} + +func NewModeDef(spec ModeSpec) ModeDef { + id := spec.ID + if id == "" { + id = CanonicalModeID(spec.Title) + } + return ModeDef{ + ID: id, + Title: spec.Title, + Description: spec.Description, + Seeded: spec.Seeded, + } +} + +func NewDefinition(spec DefinitionSpec) Definition { + id := spec.ID + if id == "" { + id = CanonicalGameID(spec.Name) + } + return Definition{ + ID: id, + Name: spec.Name, + Description: spec.Description, + Aliases: append([]string(nil), spec.Aliases...), + Modes: append([]ModeDef(nil), spec.Modes...), + DailyModeIDs: append([]ModeID(nil), spec.DailyModeIDs...), + } +} + +func SelectModeIDsByIndex(modes []ModeDef, indexes ...int) []ModeID { + selected := make([]ModeID, 0, len(indexes)) + for _, idx := range indexes { + if idx < 0 || idx >= len(modes) { + continue + } + selected = append(selected, modes[idx].ID) + } + return selected +} + func NormalizeName(s string) string { s = strings.ToLower(strings.TrimSpace(s)) s = strings.ReplaceAll(s, "-", " ") diff --git a/puzzle/puzzle_test.go b/puzzle/puzzle_test.go new file mode 100644 index 0000000..80ea751 --- /dev/null +++ b/puzzle/puzzle_test.go @@ -0,0 +1,84 @@ +package puzzle + +import "testing" + +func TestNormalizeName(t *testing.T) { + tests := []struct { + input string + want string + }{ + {input: "Word Search", want: "word search"}, + {input: "lights-out", want: "lights out"}, + {input: "word_search", want: "word search"}, + {input: " Sudoku ", want: "sudoku"}, + {input: "Sudoku RGB", want: "sudoku rgb"}, + } + + for _, tt := range tests { + if got := NormalizeName(tt.input); got != tt.want { + t.Fatalf("NormalizeName(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestCanonicalIDsUseNormalizedNames(t *testing.T) { + if got, want := CanonicalGameID(" Sudoku RGB "), GameID("sudoku rgb"); got != want { + t.Fatalf("CanonicalGameID() = %q, want %q", got, want) + } + if got, want := CanonicalModeID("Mini-Hard"), ModeID("mini hard"); got != want { + t.Fatalf("CanonicalModeID() = %q, want %q", got, want) + } +} + +func TestNewModeDefAndDefinitionCloneInput(t *testing.T) { + mode := NewModeDef(ModeSpec{ + Title: "Medium", + Description: "Balanced board", + Seeded: true, + }) + if got, want := mode.ID, ModeID("medium"); got != want { + t.Fatalf("ModeDef.ID = %q, want %q", got, want) + } + + spec := DefinitionSpec{ + Name: "Sudoku", + Description: "Classic grid logic", + Aliases: []string{"classic"}, + Modes: []ModeDef{mode}, + DailyModeIDs: []ModeID{mode.ID}, + } + def := NewDefinition(spec) + + spec.Aliases[0] = "changed" + spec.Modes[0].Title = "Changed" + spec.DailyModeIDs[0] = "changed" + + if got, want := def.ID, GameID("sudoku"); got != want { + t.Fatalf("Definition.ID = %q, want %q", got, want) + } + if got, want := def.Aliases[0], "classic"; got != want { + t.Fatalf("Aliases[0] = %q, want %q", got, want) + } + if got, want := def.Modes[0].Title, "Medium"; got != want { + t.Fatalf("Modes[0].Title = %q, want %q", got, want) + } + if got, want := def.DailyModeIDs[0], ModeID("medium"); got != want { + t.Fatalf("DailyModeIDs[0] = %q, want %q", got, want) + } +} + +func TestSelectModeIDsByIndex(t *testing.T) { + modes := []ModeDef{ + NewModeDef(ModeSpec{Title: "Beginner"}), + NewModeDef(ModeSpec{Title: "Medium"}), + NewModeDef(ModeSpec{Title: "Expert"}), + } + + got := SelectModeIDsByIndex(modes, 1, 2, 99, -1) + if len(got) != 2 { + t.Fatalf("len(SelectModeIDsByIndex) = %d, want 2", len(got)) + } + if got[0] != ModeID("medium") || got[1] != ModeID("expert") { + t.Fatalf("SelectModeIDsByIndex = %v, want [medium expert]", got) + } +} diff --git a/registry/registry.go b/registry/registry.go index a159248..f54713b 100644 --- a/registry/registry.go +++ b/registry/registry.go @@ -1,3 +1,5 @@ +// Package registry is the concrete runtime composition root for built-in games, +// imports, help text, and daily-capable modes. package registry import ( @@ -6,6 +8,7 @@ import ( "github.com/FelineStateMachine/puzzletea/catalog" "github.com/FelineStateMachine/puzzletea/fillomino" "github.com/FelineStateMachine/puzzletea/game" + "github.com/FelineStateMachine/puzzletea/gameentry" "github.com/FelineStateMachine/puzzletea/hashiwokakero" "github.com/FelineStateMachine/puzzletea/hitori" "github.com/FelineStateMachine/puzzletea/lightsout" @@ -22,18 +25,10 @@ import ( "github.com/FelineStateMachine/puzzletea/wordsearch" ) -type ModeEntry struct { - Definition puzzle.ModeDef - Spawner game.Spawner - Seeded game.SeededSpawner -} - -type Entry struct { - Definition puzzle.Definition - Help string - Import func([]byte) (game.Gamer, error) - Modes []ModeEntry -} +type ( + ModeEntry = gameentry.ModeEntry + Entry = gameentry.Entry +) type DailyEntry struct { Spawner game.SeededSpawner @@ -44,20 +39,20 @@ type DailyEntry struct { } var all = []Entry{ - adaptLegacy(fillomino.Definition), - adaptLegacy(hashiwokakero.Definition), - adaptLegacy(hitori.Definition), - adaptLegacy(lightsout.Definition), - adaptLegacy(nonogram.Definition), - adaptLegacy(nurikabe.Definition), - adaptLegacy(rippleeffect.Definition), - adaptLegacy(shikaku.Definition), - adaptLegacy(spellpuzzle.Definition), - adaptLegacy(sudoku.Definition), - adaptLegacy(sudokurgb.Definition), - adaptLegacy(takuzu.Definition), - adaptLegacy(takuzuplus.Definition), - adaptLegacy(wordsearch.Definition), + fillomino.Entry, + hashiwokakero.Entry, + hitori.Entry, + lightsout.Entry, + nonogram.Entry, + nurikabe.Entry, + rippleeffect.Entry, + shikaku.Entry, + spellpuzzle.Entry, + sudoku.Entry, + sudokurgb.Entry, + takuzu.Entry, + takuzuplus.Entry, + wordsearch.Entry, } var ( @@ -66,68 +61,6 @@ var ( entriesByID = buildEntriesByID(all) ) -func adaptLegacy(def game.Definition) Entry { - gameID := puzzle.CanonicalGameID(def.Name) - modes := make([]ModeEntry, 0, len(def.Modes)) - for _, item := range def.Modes { - mode, ok := item.(game.Mode) - if !ok { - continue - } - spawner, ok := item.(game.Spawner) - if !ok { - continue - } - modeID := puzzle.CanonicalModeID(mode.Title()) - modeDef := puzzle.ModeDef{ - ID: modeID, - Title: mode.Title(), - Description: mode.Description(), - } - var seeded game.SeededSpawner - if s, ok := item.(game.SeededSpawner); ok { - modeDef.Seeded = true - seeded = s - } - modes = append(modes, ModeEntry{ - Definition: modeDef, - Spawner: spawner, - Seeded: seeded, - }) - } - - dailyIDs := make([]puzzle.ModeID, 0, len(def.DailyModes)) - for _, item := range def.DailyModes { - mode, ok := item.(game.Mode) - if !ok { - continue - } - dailyIDs = append(dailyIDs, puzzle.CanonicalModeID(mode.Title())) - } - - return Entry{ - Definition: puzzle.Definition{ - ID: gameID, - Name: def.Name, - Description: def.Description, - Aliases: append([]string(nil), def.Aliases...), - Modes: extractModeDefs(modes), - DailyModeIDs: dailyIDs, - }, - Help: def.Help, - Import: def.Import, - Modes: modes, - } -} - -func extractModeDefs(modes []ModeEntry) []puzzle.ModeDef { - defs := make([]puzzle.ModeDef, 0, len(modes)) - for _, mode := range modes { - defs = append(defs, mode.Definition) - } - return defs -} - func buildDefinitions(entries []Entry) []puzzle.Definition { defs := make([]puzzle.Definition, 0, len(entries)) for _, entry := range entries { diff --git a/registry/registry_test.go b/registry/registry_test.go new file mode 100644 index 0000000..9a6743f --- /dev/null +++ b/registry/registry_test.go @@ -0,0 +1,189 @@ +package registry + +import ( + "go/ast" + "go/parser" + "go/token" + "path/filepath" + "reflect" + "runtime" + "slices" + "strings" + "testing" +) + +func TestResolveNormalizesSpacingAndAliases(t *testing.T) { + tests := []struct { + name string + want string + }{ + {name: "word search", want: "Word Search"}, + {name: "hashi", want: "Hashiwokakero"}, + {name: "polyomino", want: "Fillomino"}, + } + + for _, tt := range tests { + entry, ok := Resolve(tt.name) + if !ok { + t.Fatalf("Resolve(%q) = false, want true", tt.name) + } + if got := entry.Definition.Name; got != tt.want { + t.Fatalf("Resolve(%q) = %q, want %q", tt.name, got, tt.want) + } + } +} + +func TestModeSeededFlagMatchesSpawnerAvailability(t *testing.T) { + for _, entry := range Entries() { + for _, mode := range entry.Modes { + if got, want := mode.Definition.Seeded, mode.Seeded != nil; got != want { + t.Fatalf("%s/%s seeded flag = %v, want %v", + entry.Definition.Name, mode.Definition.Title, got, want) + } + } + } +} + +func TestEntriesStayAlignedWithDefinitions(t *testing.T) { + definitions := Definitions() + entries := Entries() + if got, want := len(entries), len(definitions); got != want { + t.Fatalf("len(Entries()) = %d, want %d", got, want) + } + + for _, def := range definitions { + entry, ok := Lookup(def.Name) + if !ok { + t.Fatalf("Lookup(%q) = false", def.Name) + } + if entry.Import == nil { + t.Fatalf("%s missing import function", def.Name) + } + if got, want := len(entry.Modes), len(def.Modes); got != want { + t.Fatalf("%s mode count = %d, want %d", def.Name, got, want) + } + } +} + +func TestRegistryAllMatchesConcreteGamePackages(t *testing.T) { + got := registryAllImportPaths(t) + want := concreteGameImportPaths(t) + if !reflect.DeepEqual(got, want) { + t.Fatalf("registry all import paths = %v, want %v", got, want) + } +} + +func TestPrintAdaptersStayAlignedWithRegistryEntries(t *testing.T) { + for _, entry := range Entries() { + if entry.Definition.Name == "Lights Out" { + if entry.Print != nil { + t.Fatal("Lights Out should not expose a print adapter") + } + continue + } + if entry.Print == nil { + t.Fatalf("%s missing print adapter", entry.Definition.Name) + } + } +} + +func registryAllImportPaths(t *testing.T) []string { + t.Helper() + + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "registry.go", nil, 0) + if err != nil { + t.Fatalf("parse registry.go: %v", err) + } + + imports := make(map[string]string) + for _, imp := range file.Imports { + path := strings.Trim(imp.Path.Value, `"`) + name := importName(path, imp.Name) + imports[name] = path + } + + paths := registryAllSelectorImportPaths(t, file, imports) + slices.Sort(paths) + return paths +} + +func registryAllSelectorImportPaths(t *testing.T, file *ast.File, imports map[string]string) []string { + t.Helper() + + for _, decl := range file.Decls { + genDecl, ok := decl.(*ast.GenDecl) + if !ok || genDecl.Tok != token.VAR { + continue + } + for _, spec := range genDecl.Specs { + valueSpec, ok := spec.(*ast.ValueSpec) + if !ok || len(valueSpec.Names) != 1 || valueSpec.Names[0].Name != "all" || len(valueSpec.Values) != 1 { + continue + } + + lit, ok := valueSpec.Values[0].(*ast.CompositeLit) + if !ok { + t.Fatalf("registry all value is %T, want composite literal", valueSpec.Values[0]) + } + + paths := make([]string, 0, len(lit.Elts)) + for _, elt := range lit.Elts { + selector, ok := elt.(*ast.SelectorExpr) + if !ok { + t.Fatalf("registry all element is %T, want selector", elt) + } + pkg, ok := selector.X.(*ast.Ident) + if !ok { + t.Fatalf("registry all selector receiver is %T, want identifier", selector.X) + } + + path, ok := imports[pkg.Name] + if !ok { + t.Fatalf("registry all package %q missing from imports", pkg.Name) + } + paths = append(paths, path) + } + return paths + } + } + + t.Fatal("registry all declaration not found") + return nil +} + +func importName(path string, name *ast.Ident) string { + if name != nil { + return name.Name + } + parts := strings.Split(path, "/") + return parts[len(parts)-1] +} + +func concreteGameImportPaths(t testing.TB) []string { + t.Helper() + + pattern := filepath.Join(repoRoot(t), "*", "gamemode.go") + matches, err := filepath.Glob(pattern) + if err != nil { + t.Fatalf("glob concrete game packages: %v", err) + } + + importPaths := make([]string, 0, len(matches)) + for _, match := range matches { + dir := filepath.Base(filepath.Dir(match)) + importPaths = append(importPaths, "github.com/FelineStateMachine/puzzletea/"+dir) + } + slices.Sort(importPaths) + return importPaths +} + +func repoRoot(t testing.TB) string { + t.Helper() + + _, path, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + return filepath.Clean(filepath.Join(filepath.Dir(path), "..")) +} diff --git a/rippleeffect/README.md b/rippleeffect/README.md index 82d5e34..dfa6817 100644 --- a/rippleeffect/README.md +++ b/rippleeffect/README.md @@ -2,6 +2,28 @@ Place digits into irregular cages so each cage contains `1..n` exactly once, and matching digits stay outside each other’s ripple distance. +![Ripple Effect gameplay](../vhs/rippleeffect.gif) + +## How to Play + +Each outlined cage must contain the digits `1..n` exactly once, where `n` is +that cage's size. Matching digits also create a "ripple" that blocks the next +`n-1` cells in the same row and column from containing the same value. + +Given cells are fixed. The puzzle is solved when every cell is filled and both +the cage and ripple rules are satisfied. + +## Controls + +| Key | Action | +|-----|--------| +| Arrow keys / WASD / hjkl | Move cursor | +| `1`-`9` | Place number | +| `Backspace` / `Delete` | Clear cell | +| `Ctrl+R` | Reset puzzle | +| `Ctrl+H` | Toggle full help | +| `Escape` | Return to main menu | + ## Modes | Mode | Size | Notes | @@ -12,7 +34,7 @@ Place digits into irregular cages so each cage contains `1..n` exactly once, and | Hard 8x8 | 8x8 | Longer cages with lighter anchoring | | Expert 9x9 | 9x9 | Winding large cages and sparse anchors | -## CLI +## Quick Start ```bash puzzletea new ripple-effect diff --git a/rippleeffect/Export.go b/rippleeffect/export.go similarity index 100% rename from rippleeffect/Export.go rename to rippleeffect/export.go diff --git a/rippleeffect/Gamemode.go b/rippleeffect/gamemode.go similarity index 80% rename from rippleeffect/Gamemode.go rename to rippleeffect/gamemode.go index 7ac7d94..92c762a 100644 --- a/rippleeffect/Gamemode.go +++ b/rippleeffect/gamemode.go @@ -4,8 +4,9 @@ import ( _ "embed" "math/rand/v2" - "charm.land/bubbles/v2/list" "github.com/FelineStateMachine/puzzletea/game" + "github.com/FelineStateMachine/puzzletea/gameentry" + "github.com/FelineStateMachine/puzzletea/puzzle" ) //go:embed help.md @@ -55,7 +56,7 @@ func (m Mode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) { return New(m, puzzle) } -var Modes = []list.Item{ +var Modes = []game.Mode{ NewModeWithProfile( "Mini 5x5", "Compact cages with extra anchors for quick local reads.", @@ -123,21 +124,23 @@ var Modes = []list.Item{ ), } -var DailyModes = []list.Item{ - Modes[1], - Modes[2], - Modes[3], -} +var ModeDefinitions = gameentry.BuildModeDefs(Modes) -var Definition = game.Definition{ - Name: "Ripple Effect", - Description: "Fill the cages with sequential numbers without violating ripple distance.", - Aliases: []string{"ripple"}, - Modes: Modes, - DailyModes: DailyModes, - Help: HelpContent, - Import: func(data []byte) (game.Gamer, error) { return ImportModel(data) }, -} +var Definition = puzzle.NewDefinition(puzzle.DefinitionSpec{ + Name: "Ripple Effect", + Description: "Fill the cages with sequential numbers without violating ripple distance.", + Aliases: []string{"ripple"}, + Modes: ModeDefinitions, + DailyModeIDs: puzzle.SelectModeIDsByIndex(ModeDefinitions, 1, 2, 3), +}) + +var Entry = gameentry.NewEntry(gameentry.EntrySpec{ + Definition: Definition, + Help: HelpContent, + Import: game.AdaptImport(ImportModel), + Modes: Modes, + Print: PDFPrintAdapter, +}) func (m Mode) generatePuzzle() (Puzzle, error) { rng := rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64())) diff --git a/rippleeffect/help.md b/rippleeffect/help.md index 0f57a0a..670b5a8 100644 --- a/rippleeffect/help.md +++ b/rippleeffect/help.md @@ -16,7 +16,7 @@ Fill each cage with the digits `1` through the cage size, then keep equal digits | `Arrows` / `wasd` / `hjkl` | Move cursor | | `1`-`9` | Place number | | `Backspace` / `Delete` | Clear cell | -| `Ctrl+H` | Toggle help bar | +| `Ctrl+H` | Toggle full help | | `Ctrl+R` | Reset puzzle | | `Escape` | Return to main menu | diff --git a/rippleeffect/Model.go b/rippleeffect/model.go similarity index 100% rename from rippleeffect/Model.go rename to rippleeffect/model.go diff --git a/rippleeffect/PrintAdapter.go b/rippleeffect/print_adapter.go similarity index 98% rename from rippleeffect/PrintAdapter.go rename to rippleeffect/print_adapter.go index 9ddcea1..e266d04 100644 --- a/rippleeffect/PrintAdapter.go +++ b/rippleeffect/print_adapter.go @@ -9,6 +9,8 @@ import ( type printAdapter struct{} +var PDFPrintAdapter = printAdapter{} + func (printAdapter) CanonicalGameType() string { return "Ripple Effect" } func (printAdapter) Aliases() []string { return []string{"ripple effect", "rippleeffect", "ripple"} } @@ -119,7 +121,3 @@ func drawRippleEffectGiven(pdf *fpdf.Fpdf, x, y, cellSize float64, text string) pdf.SetXY(x, y+(cellSize-lineH)/2) pdf.CellFormat(cellSize, lineH, text, "", 0, "C", false, 0, "") } - -func init() { - pdfexport.RegisterPrintAdapter(printAdapter{}) -} diff --git a/schedule/select.go b/schedule/select.go new file mode 100644 index 0000000..94f0bbb --- /dev/null +++ b/schedule/select.go @@ -0,0 +1,47 @@ +package schedule + +import ( + "hash/fnv" + + "github.com/FelineStateMachine/puzzletea/game" + "github.com/FelineStateMachine/puzzletea/registry" +) + +type Entry struct { + Spawner game.SeededSpawner + GameType string + Mode string +} + +func BuildEligibleModes(entries []registry.DailyEntry) []Entry { + result := make([]Entry, 0, len(entries)) + for _, entry := range entries { + result = append(result, Entry{ + Spawner: entry.Spawner, + GameType: entry.GameType, + Mode: entry.Mode, + }) + } + return result +} + +func SelectBySeed(seed string, entries []Entry) (Entry, bool) { + var best Entry + var bestHash uint64 + found := false + for _, entry := range entries { + h := fnv.New64a() + h.Write([]byte(seed)) + h.Write([]byte{0}) + h.Write([]byte(entry.GameType)) + h.Write([]byte{0}) + h.Write([]byte(entry.Mode)) + score := h.Sum64() + if !found || score > bestHash { + bestHash = score + best = entry + found = true + } + } + return best, found +} diff --git a/schedule/select_test.go b/schedule/select_test.go new file mode 100644 index 0000000..ae7c1be --- /dev/null +++ b/schedule/select_test.go @@ -0,0 +1,69 @@ +package schedule + +import ( + "math/rand/v2" + "testing" + + "github.com/FelineStateMachine/puzzletea/game" +) + +type testSeededSpawner struct{} + +func (testSeededSpawner) Spawn() (game.Gamer, error) { return nil, nil } +func (testSeededSpawner) SpawnSeeded(*rand.Rand) (game.Gamer, error) { return nil, nil } + +func TestSelectBySeedDeterministic(t *testing.T) { + entries := []Entry{ + {Spawner: testSeededSpawner{}, GameType: "Sudoku", Mode: "Easy"}, + {Spawner: testSeededSpawner{}, GameType: "Nonogram", Mode: "Mini"}, + {Spawner: testSeededSpawner{}, GameType: "Takuzu", Mode: "Beginner"}, + } + + first, ok := SelectBySeed("seed-1", entries) + if !ok { + t.Fatal("SelectBySeed() = false, want true") + } + second, ok := SelectBySeed("seed-1", entries) + if !ok { + t.Fatal("SelectBySeed() = false, want true") + } + if first.GameType != second.GameType || first.Mode != second.Mode { + t.Fatalf("selection mismatch: (%q,%q) vs (%q,%q)", + first.GameType, first.Mode, second.GameType, second.Mode) + } +} + +func TestSelectBySeedStableForUnchangedWinners(t *testing.T) { + entries := []Entry{ + {Spawner: testSeededSpawner{}, GameType: "Sudoku", Mode: "Easy"}, + {Spawner: testSeededSpawner{}, GameType: "Nonogram", Mode: "Mini"}, + {Spawner: testSeededSpawner{}, GameType: "Takuzu", Mode: "Beginner"}, + } + + seeds := []string{"seed-a", "seed-b", "seed-c", "seed-d", "seed-e"} + original := make(map[string]Entry, len(seeds)) + for _, seed := range seeds { + selected, ok := SelectBySeed(seed, entries) + if !ok { + t.Fatalf("SelectBySeed(%q) = false, want true", seed) + } + original[seed] = selected + } + + synthetic := Entry{Spawner: testSeededSpawner{}, GameType: "Synthetic", Mode: "Synthetic"} + extended := append(append([]Entry(nil), entries...), synthetic) + for _, seed := range seeds { + selected, ok := SelectBySeed(seed, extended) + if !ok { + t.Fatalf("SelectBySeed(%q) = false, want true", seed) + } + if selected.GameType == synthetic.GameType && selected.Mode == synthetic.Mode { + continue + } + want := original[seed] + if selected.GameType != want.GameType || selected.Mode != want.Mode { + t.Fatalf("seed %q changed from (%q,%q) to (%q,%q)", + seed, want.GameType, want.Mode, selected.GameType, selected.Mode) + } + } +} diff --git a/session/session.go b/session/session.go index 6592170..4c10078 100644 --- a/session/session.go +++ b/session/session.go @@ -66,6 +66,7 @@ func CreateRecord( name string, gameType string, modeTitle string, + run store.RunMetadata, ) (*store.GameRecord, error) { initialState, err := g.GetSave() if err != nil { @@ -81,14 +82,12 @@ func CreateRecord( InitialState: string(initialState), SaveState: string(initialState), Status: store.StatusNew, - RunKind: store.RunKindForName(name), - RunDate: store.RunDateForName(name), - SeedText: store.SeedTextForName(name), - } - if year, week, index, ok := store.WeeklyIdentityForName(name); ok { - rec.WeekYear = year - rec.WeekNumber = week - rec.WeekIndex = index + RunKind: run.Kind, + RunDate: run.Date, + WeekYear: run.WeekYear, + WeekNumber: run.WeekNumber, + WeekIndex: run.WeekIndex, + SeedText: run.SeedText, } if err := s.CreateGame(rec); err != nil { return nil, fmt.Errorf("failed to create game record: %w", err) @@ -117,8 +116,9 @@ func GenerateUniqueName(s *store.Store) string { return name } } + base := namegen.Generate() for i := 1; ; i++ { - name := namegen.Generate() + "-" + strconv.Itoa(i) + name := base + "-" + strconv.Itoa(i) exists, err := s.NameExists(name) if err != nil || !exists { return name diff --git a/session/session_test.go b/session/session_test.go index bec038e..d2f7714 100644 --- a/session/session_test.go +++ b/session/session_test.go @@ -3,6 +3,7 @@ package session import ( "path/filepath" "testing" + "time" "github.com/FelineStateMachine/puzzletea/lightsout" "github.com/FelineStateMachine/puzzletea/store" @@ -77,7 +78,7 @@ func TestCreateRecordAndResumeAbandonedDeterministicRecord(t *testing.T) { if err != nil { t.Fatal(err) } - rec, err := CreateRecord(s, g, "demo", "Lights Out", "Easy") + rec, err := CreateRecord(s, g, "demo", "Lights Out", "Easy", store.NormalRunMetadata()) if err != nil { t.Fatalf("CreateRecord returned error: %v", err) } @@ -105,6 +106,30 @@ func TestCreateRecordAndResumeAbandonedDeterministicRecord(t *testing.T) { } } +func TestCreateRecordUsesExplicitRunMetadata(t *testing.T) { + s := openSessionTestStore(t) + + g, err := lightsout.New(3, 3) + if err != nil { + t.Fatal(err) + } + + day := time.Date(2026, 2, 14, 15, 30, 0, 0, time.Local) + rec, err := CreateRecord(s, g, "plain-name", "Lights Out", "Easy", store.DailyRunMetadata(day)) + if err != nil { + t.Fatalf("CreateRecord returned error: %v", err) + } + if rec.RunKind != store.RunKindDaily { + t.Fatalf("RunKind = %q, want %q", rec.RunKind, store.RunKindDaily) + } + if rec.RunDate == nil { + t.Fatal("RunDate = nil, want daily date") + } + if got, want := rec.RunDate.Format("2006-01-02"), "2026-02-14"; got != want { + t.Fatalf("RunDate = %q, want %q", got, want) + } +} + func openSessionTestStore(t *testing.T) *store.Store { t.Helper() s, err := store.Open(filepath.Join(t.TempDir(), "session.db")) diff --git a/shikaku/README.md b/shikaku/README.md index 56498cf..c0e14a9 100644 --- a/shikaku/README.md +++ b/shikaku/README.md @@ -27,7 +27,6 @@ clue and the clue's value equals the rectangle's area. | `Backspace` | Cancel preview or delete rectangle at cursor | | `Ctrl+R` | Reset puzzle | | `Ctrl+H` | Toggle full help | -| `Ctrl+E` | Toggle debug overlay | | `Escape` | Return to main menu | ### Expansion Mode (after selecting a clue) diff --git a/shikaku/Export.go b/shikaku/export.go similarity index 100% rename from shikaku/Export.go rename to shikaku/export.go diff --git a/shikaku/Gamemode.go b/shikaku/gamemode.go similarity index 66% rename from shikaku/Gamemode.go rename to shikaku/gamemode.go index f9ad241..6df3d37 100644 --- a/shikaku/Gamemode.go +++ b/shikaku/gamemode.go @@ -4,8 +4,9 @@ import ( _ "embed" "math/rand/v2" - "charm.land/bubbles/v2/list" "github.com/FelineStateMachine/puzzletea/game" + "github.com/FelineStateMachine/puzzletea/gameentry" + "github.com/FelineStateMachine/puzzletea/puzzle" ) //go:embed help.md @@ -49,7 +50,7 @@ func (s ShikakuMode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) { return New(s, puzzle), nil } -var Modes = []list.Item{ +var Modes = []game.Mode{ NewMode("Mini 5x5", "5x5 grid, gentle introduction.", 5, 5, 5), NewMode("Easy 7x7", "7x7 grid, straightforward puzzles.", 7, 7, 8), NewMode("Medium 8x8", "8x8 grid, moderate challenge.", 8, 8, 12), @@ -57,17 +58,20 @@ var Modes = []list.Item{ NewMode("Expert 12x12", "12x12 grid, maximum challenge.", 12, 12, 20), } -var DailyModes = []list.Item{ - Modes[1], // Easy 7x7 - Modes[2], // Medium 8x8 -} +var ModeDefinitions = gameentry.BuildModeDefs(Modes) -var Definition = game.Definition{ - Name: "Shikaku", - Description: "Divide the grid into rectangles with set sizes.", - Aliases: []string{"rectangles"}, - Modes: Modes, - DailyModes: DailyModes, - Help: HelpContent, - Import: func(data []byte) (game.Gamer, error) { return ImportModel(data) }, -} +var Definition = puzzle.NewDefinition(puzzle.DefinitionSpec{ + Name: "Shikaku", + Description: "Divide the grid into rectangles with set sizes.", + Aliases: []string{"rectangles"}, + Modes: ModeDefinitions, + DailyModeIDs: puzzle.SelectModeIDsByIndex(ModeDefinitions, 1, 2), +}) + +var Entry = gameentry.NewEntry(gameentry.EntrySpec{ + Definition: Definition, + Help: HelpContent, + Import: game.AdaptImport(ImportModel), + Modes: Modes, + Print: PDFPrintAdapter, +}) diff --git a/shikaku/help.md b/shikaku/help.md index cc7a5e1..2f3ba9d 100644 --- a/shikaku/help.md +++ b/shikaku/help.md @@ -50,7 +50,7 @@ Expansion Mode so you can fine-tune with the keyboard. | Key | Action | |-----|--------| | `Ctrl+R` | Reset puzzle | -| `Ctrl+H` | Toggle help bar | +| `Ctrl+H` | Toggle full help | | `Escape` | Return to main menu | ## Tips diff --git a/shikaku/Model.go b/shikaku/model.go similarity index 100% rename from shikaku/Model.go rename to shikaku/model.go diff --git a/shikaku/PrintAdapter.go b/shikaku/print_adapter.go similarity index 97% rename from shikaku/PrintAdapter.go rename to shikaku/print_adapter.go index fe59d5e..fc5546c 100644 --- a/shikaku/PrintAdapter.go +++ b/shikaku/print_adapter.go @@ -10,6 +10,8 @@ import ( type printAdapter struct{} +var PDFPrintAdapter = printAdapter{} + func (printAdapter) CanonicalGameType() string { return "Shikaku" } func (printAdapter) Aliases() []string { return []string{"shikaku"} } @@ -95,7 +97,3 @@ func drawShikakuClue(pdf *fpdf.Fpdf, x, y, cellSize float64, value int) { pdf.SetXY(x, y+(cellSize-lineH)/2) pdf.CellFormat(cellSize, lineH, text, "", 0, "C", false, 0, "") } - -func init() { - pdfexport.RegisterPrintAdapter(printAdapter{}) -} diff --git a/spellpuzzle/Export.go b/spellpuzzle/export.go similarity index 100% rename from spellpuzzle/Export.go rename to spellpuzzle/export.go diff --git a/spellpuzzle/Gamemode.go b/spellpuzzle/gamemode.go similarity index 65% rename from spellpuzzle/Gamemode.go rename to spellpuzzle/gamemode.go index 047c394..aba3657 100644 --- a/spellpuzzle/Gamemode.go +++ b/spellpuzzle/gamemode.go @@ -4,8 +4,9 @@ import ( _ "embed" "math/rand/v2" - "charm.land/bubbles/v2/list" "github.com/FelineStateMachine/puzzletea/game" + "github.com/FelineStateMachine/puzzletea/gameentry" + "github.com/FelineStateMachine/puzzletea/puzzle" ) //go:embed help.md @@ -49,23 +50,27 @@ func (m SpellPuzzleMode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) { return New(m, puzzle) } -var Modes = []list.Item{ +var Modes = []game.Mode{ NewMode("Beginner", "6 letters, 4 board words, gentle intro.", 6, 4, 3), NewMode("Easy", "7 letters, 6 board words, balanced start.", 7, 6, 4), NewMode("Medium", "8 letters, 8 board words, denser layout.", 8, 8, 6), NewMode("Hard", "9 letters, 9 board words, largest launch board.", 9, 9, 8), } -var DailyModes = []list.Item{ - Modes[0], -} +var ModeDefinitions = gameentry.BuildModeDefs(Modes) -var Definition = game.Definition{ - Name: "Spell Puzzle", - Description: "Connect letters to fill a crossword with bonus anagrams.", - Aliases: []string{"spell", "spellpuzzle"}, - Modes: Modes, - DailyModes: DailyModes, - Help: HelpContent, - Import: func(data []byte) (game.Gamer, error) { return ImportModel(data) }, -} +var Definition = puzzle.NewDefinition(puzzle.DefinitionSpec{ + Name: "Spell Puzzle", + Description: "Connect letters to fill a crossword with bonus anagrams.", + Aliases: []string{"spell", "spellpuzzle"}, + Modes: ModeDefinitions, + DailyModeIDs: puzzle.SelectModeIDsByIndex(ModeDefinitions, 0), +}) + +var Entry = gameentry.NewEntry(gameentry.EntrySpec{ + Definition: Definition, + Help: HelpContent, + Import: game.AdaptImport(ImportModel), + Modes: Modes, + Print: PDFPrintAdapter, +}) diff --git a/spellpuzzle/help.md b/spellpuzzle/help.md index b9a5769..8bcd449 100644 --- a/spellpuzzle/help.md +++ b/spellpuzzle/help.md @@ -21,7 +21,7 @@ Connect letters from the bank to spell valid words. | `1` | Shuffle the visible bank order | | `Enter` | Submit the traced word | | `Backspace` | Delete the last traced letter | -| `Ctrl+H` | Toggle help bar | +| `Ctrl+H` | Toggle full help | | `Ctrl+R` | Reset puzzle | | `Escape` | Return to main menu | diff --git a/spellpuzzle/Model.go b/spellpuzzle/model.go similarity index 100% rename from spellpuzzle/Model.go rename to spellpuzzle/model.go diff --git a/spellpuzzle/PrintAdapter.go b/spellpuzzle/print_adapter.go similarity index 98% rename from spellpuzzle/PrintAdapter.go rename to spellpuzzle/print_adapter.go index 7ddb21f..0231f93 100644 --- a/spellpuzzle/PrintAdapter.go +++ b/spellpuzzle/print_adapter.go @@ -11,6 +11,8 @@ import ( type printAdapter struct{} +var PDFPrintAdapter = printAdapter{} + type printPayload struct { Board board Placements []WordPlacement @@ -226,7 +228,3 @@ func drawSpellPuzzleBank(pdf *fpdf.Fpdf, bank []rune, layout printLayout) { pdf.CellFormat(layout.tileSize, lineH, strings.ToUpper(string(letter)), "", 0, "C", false, 0, "") } } - -func init() { - pdfexport.RegisterPrintAdapter(printAdapter{}) -} diff --git a/spellpuzzle/print_adapter_test.go b/spellpuzzle/print_adapter_test.go index 7a8c788..6e005a9 100644 --- a/spellpuzzle/print_adapter_test.go +++ b/spellpuzzle/print_adapter_test.go @@ -7,7 +7,6 @@ import ( "testing" "time" - "github.com/FelineStateMachine/puzzletea/game" "github.com/FelineStateMachine/puzzletea/pdfexport" ) @@ -95,14 +94,18 @@ func TestPrintAdapterDeduplicatesDuplicatePlacements(t *testing.T) { } func TestSpellPuzzlePrintAdapterRegistration(t *testing.T) { + pdfexport.RegisterPrintAdapter(PDFPrintAdapter) + for _, gameType := range []string{"Spell Puzzle", "spell", "spellpuzzle"} { - if !game.HasPrintAdapter(gameType) { + if !pdfexport.HasPrintAdapter(gameType) { t.Fatalf("expected print adapter for %q", gameType) } } } func TestSpellPuzzleJSONLHydratesPrintPayload(t *testing.T) { + pdfexport.RegisterPrintAdapter(PDFPrintAdapter) + record := pdfexport.JSONLRecord{ Schema: pdfexport.ExportSchemaV1, Pack: pdfexport.JSONLPackMeta{ @@ -151,6 +154,8 @@ func TestSpellPuzzleJSONLHydratesPrintPayload(t *testing.T) { } func TestSpellPuzzlePDFRenderSmokeForAllModes(t *testing.T) { + pdfexport.RegisterPrintAdapter(PDFPrintAdapter) + puzzles := make([]pdfexport.Puzzle, 0, len(Modes)) for i, item := range Modes { mode := item.(SpellPuzzleMode) diff --git a/store/game_record.go b/store/game_record.go index 8a10962..8c541bb 100644 --- a/store/game_record.go +++ b/store/game_record.go @@ -7,6 +7,15 @@ type ( RunKind string ) +type RunMetadata struct { + Kind RunKind + Date *time.Time + WeekYear int + WeekNumber int + WeekIndex int + SeedText string +} + const ( StatusNew GameStatus = "new" StatusInProgress GameStatus = "in_progress" diff --git a/store/metadata.go b/store/metadata.go index 9dc171c..8ec6247 100644 --- a/store/metadata.go +++ b/store/metadata.go @@ -11,6 +11,34 @@ import ( var weeklyNamePattern = regexp.MustCompile(`^Week (\d{2})-(\d{4}) - #(\d{2})$`) +func NormalRunMetadata() RunMetadata { + return RunMetadata{Kind: RunKindNormal} +} + +func DailyRunMetadata(date time.Time) RunMetadata { + day := dayOnly(date) + return RunMetadata{ + Kind: RunKindDaily, + Date: &day, + } +} + +func WeeklyRunMetadata(year, week, index int) RunMetadata { + return RunMetadata{ + Kind: RunKindWeekly, + WeekYear: year, + WeekNumber: week, + WeekIndex: index, + } +} + +func SeededRunMetadata(seed string) RunMetadata { + return RunMetadata{ + Kind: RunKindSeeded, + SeedText: strings.TrimSpace(seed), + } +} + func RunKindForName(name string) RunKind { switch { case RunDateForName(name) != nil: @@ -36,7 +64,7 @@ func RunDateForName(name string) *time.Time { if err != nil { return nil } - day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.Local) + day := dayOnly(t) return &day } @@ -91,3 +119,7 @@ func isWeeklyName(name string) bool { _, _, _, ok := WeeklyIdentityForName(name) return ok } + +func dayOnly(t time.Time) time.Time { + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) +} diff --git a/store/migrate.go b/store/migrate.go index ded3080..da0010b 100644 --- a/store/migrate.go +++ b/store/migrate.go @@ -5,6 +5,20 @@ import ( "fmt" ) +const currentSchemaVersion = 3 + +type migration struct { + version int + name string + apply func(*sql.DB) error +} + +var schemaMigrations = []migration{ + {version: 1, name: "create games table", apply: createGamesSchema}, + {version: 2, name: "add game metadata columns", apply: migrateGameMetadata}, + {version: 3, name: "refresh stats views", apply: refreshStatsViews}, +} + type gameRowMeta struct { ID int64 Name string @@ -15,6 +29,145 @@ type gameRowMeta struct { RunKind string } +func runMigrations(db *sql.DB) error { + if err := ensureSchemaMigrationsTable(db); err != nil { + return err + } + + version, err := schemaVersion(db) + if err != nil { + return err + } + if version == 0 { + version, err = detectSchemaVersion(db) + if err != nil { + return err + } + if err := setSchemaVersion(db, version); err != nil { + return err + } + } + + for _, m := range schemaMigrations { + if m.version <= version { + continue + } + if err := m.apply(db); err != nil { + return fmt.Errorf("applying migration %d (%s): %w", m.version, m.name, err) + } + if err := setSchemaVersion(db, m.version); err != nil { + return err + } + } + + return nil +} + +func ensureSchemaMigrationsTable(db *sql.DB) error { + _, err := db.Exec(` +CREATE TABLE IF NOT EXISTS schema_migrations ( + id INTEGER PRIMARY KEY CHECK(id = 1), + version INTEGER NOT NULL, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +);`) + if err != nil { + return fmt.Errorf("creating schema_migrations table: %w", err) + } + return nil +} + +func schemaVersion(db *sql.DB) (int, error) { + var version int + err := db.QueryRow(`SELECT version FROM schema_migrations WHERE id = 1`).Scan(&version) + if err == sql.ErrNoRows { + return 0, nil + } + if err != nil { + return 0, fmt.Errorf("reading schema version: %w", err) + } + return version, nil +} + +func setSchemaVersion(db *sql.DB, version int) error { + _, err := db.Exec(` +INSERT INTO schema_migrations (id, version) +VALUES (1, ?) +ON CONFLICT(id) DO UPDATE +SET version = excluded.version, + updated_at = CURRENT_TIMESTAMP +`, version) + if err != nil { + return fmt.Errorf("writing schema version %d: %w", version, err) + } + return nil +} + +func detectSchemaVersion(db *sql.DB) (int, error) { + exists, err := tableExists(db, "games") + if err != nil { + return 0, err + } + if !exists { + return 0, nil + } + + columns, err := tableColumns(db, "games") + if err != nil { + return 0, err + } + + required := []string{ + "game_id", + "mode_id", + "run_kind", + "run_date", + "week_year", + "week_number", + "week_index", + "seed_text", + } + for _, column := range required { + if !columns[column] { + return 1, nil + } + } + + return 2, nil +} + +func createGamesSchema(db *sql.DB) error { + if _, err := db.Exec(createTableSQL); err != nil { + return fmt.Errorf("creating games table: %w", err) + } + return nil +} + +func migrateGameMetadata(db *sql.DB) error { + if err := ensureGameColumns(db); err != nil { + return err + } + if err := backfillGameMetadata(db); err != nil { + return err + } + return nil +} + +func refreshStatsViews(db *sql.DB) error { + if _, err := db.Exec(`DROP VIEW IF EXISTS category_stats`); err != nil { + return fmt.Errorf("dropping category_stats view: %w", err) + } + if _, err := db.Exec(`DROP VIEW IF EXISTS mode_stats`); err != nil { + return fmt.Errorf("dropping mode_stats view: %w", err) + } + if _, err := db.Exec(createCategoryStatsViewSQL); err != nil { + return fmt.Errorf("creating category_stats view: %w", err) + } + if _, err := db.Exec(createModeStatsViewSQL); err != nil { + return fmt.Errorf("creating mode_stats view: %w", err) + } + return nil +} + func ensureGameColumns(db *sql.DB) error { columns := []struct { name string @@ -70,6 +223,18 @@ func tableColumns(db *sql.DB, table string) (map[string]bool, error) { return columns, rows.Err() } +func tableExists(db *sql.DB, table string) (bool, error) { + var name string + err := db.QueryRow(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`, table).Scan(&name) + if err == sql.ErrNoRows { + return false, nil + } + if err != nil { + return false, fmt.Errorf("checking %s existence: %w", table, err) + } + return true, nil +} + func backfillGameMetadata(db *sql.DB) error { rows, err := db.Query(`SELECT id, name, game_type, mode, game_id, mode_id, run_kind FROM games`) if err != nil { diff --git a/store/stats_test.go b/store/stats_test.go index 4a92ccd..e2be20b 100644 --- a/store/stats_test.go +++ b/store/stats_test.go @@ -81,10 +81,14 @@ func TestGetCategoryStats(t *testing.T) { t.Run("daily games counted", func(t *testing.T) { s := openTestStore(t) games := []*GameRecord{ - {Name: "Daily Feb 16 26 - amber-fox", GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew}, - {Name: "Daily Feb 15 26 - blue-cat", GameType: "Sudoku", Mode: "Medium", InitialState: "{}", SaveState: "{}", Status: StatusNew}, + newDailyTestRecord("Daily Feb 16 26 - amber-fox", time.Date(2026, time.February, 16, 12, 0, 0, 0, time.Local)), + newDailyTestRecord("Daily Feb 15 26 - blue-cat", time.Date(2026, time.February, 15, 12, 0, 0, 0, time.Local)), {Name: "regular-game", GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew}, } + games[0].GameType, games[0].Mode = "Sudoku", "Easy" + games[0].InitialState, games[0].SaveState, games[0].Status = "{}", "{}", StatusNew + games[1].GameType, games[1].Mode = "Sudoku", "Medium" + games[1].InitialState, games[1].SaveState, games[1].Status = "{}", "{}", StatusNew for _, g := range games { if err := s.CreateGame(g); err != nil { t.Fatal(err) @@ -131,8 +135,10 @@ func TestGetModeStats(t *testing.T) { {Name: "a", GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew}, {Name: "b", GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew}, {Name: "c", GameType: "Sudoku", Mode: "Medium", InitialState: "{}", SaveState: "{}", Status: StatusNew}, - {Name: "Daily Feb 16 26 - x", GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew}, + newDailyTestRecord("Daily Feb 16 26 - x", time.Date(2026, time.February, 16, 12, 0, 0, 0, time.Local)), } + games[3].GameType, games[3].Mode = "Sudoku", "Easy" + games[3].InitialState, games[3].SaveState, games[3].Status = "{}", "{}", StatusNew for _, g := range games { if err := s.CreateGame(g); err != nil { t.Fatal(err) @@ -176,8 +182,15 @@ func TestGetModeStats(t *testing.T) { t.Run("weekly bonus xp is aggregated by parsed name", func(t *testing.T) { s := openTestStore(t) games := []*GameRecord{ - {Name: weekly.Name(2026, 1, 10), GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew}, - {Name: weekly.Name(2026, 1, 25), GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew}, + newWeeklyTestRecord(weekly.Name(2026, 1, 10), 2026, 1, 10), + newWeeklyTestRecord(weekly.Name(2026, 1, 25), 2026, 1, 25), + } + for _, g := range games { + g.GameType = "Sudoku" + g.Mode = "Easy" + g.InitialState = "{}" + g.SaveState = "{}" + g.Status = StatusNew } for _, g := range games { if err := s.CreateGame(g); err != nil { @@ -230,10 +243,14 @@ func TestGetDailyStreakDates(t *testing.T) { yesterday := now.AddDate(0, 0, -1) games := []*GameRecord{ - {Name: "Daily Feb 16 26 - a", GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew}, - {Name: "Daily Feb 15 26 - b", GameType: "Nonogram", Mode: "Mini", InitialState: "{}", SaveState: "{}", Status: StatusNew}, + newDailyTestRecord("Daily Feb 16 26 - a", time.Date(2026, time.February, 16, 12, 0, 0, 0, time.Local)), + newDailyTestRecord("Daily Feb 15 26 - b", time.Date(2026, time.February, 15, 12, 0, 0, 0, time.Local)), {Name: "regular-game", GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew}, } + games[0].GameType, games[0].Mode = "Sudoku", "Easy" + games[0].InitialState, games[0].SaveState, games[0].Status = "{}", "{}", StatusNew + games[1].GameType, games[1].Mode = "Nonogram", "Mini" + games[1].InitialState, games[1].SaveState, games[1].Status = "{}", "{}", StatusNew for _, g := range games { if err := s.CreateGame(g); err != nil { t.Fatal(err) @@ -268,8 +285,15 @@ func TestGetDailyStreakDates(t *testing.T) { t.Run("excludes non-completed dailies", func(t *testing.T) { s := openTestStore(t) games := []*GameRecord{ - {Name: "Daily Feb 16 26 - a", GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew}, - {Name: "Daily Feb 15 26 - b", GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew}, + newDailyTestRecord("Daily Feb 16 26 - a", time.Date(2026, time.February, 16, 12, 0, 0, 0, time.Local)), + newDailyTestRecord("Daily Feb 15 26 - b", time.Date(2026, time.February, 15, 12, 0, 0, 0, time.Local)), + } + for _, g := range games { + g.GameType = "Sudoku" + g.Mode = "Easy" + g.InitialState = "{}" + g.SaveState = "{}" + g.Status = StatusNew } for _, g := range games { if err := s.CreateGame(g); err != nil { @@ -295,12 +319,19 @@ func TestGetCompletedWeeklyGauntlets(t *testing.T) { s := openTestStore(t) games := []*GameRecord{ - {Name: weekly.Name(2026, 1, 98), GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew}, - {Name: weekly.Name(2026, 1, 99), GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew}, - {Name: weekly.Name(2026, 2, 50), GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew}, - {Name: weekly.Name(2026, 3, 99), GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew}, + newWeeklyTestRecord(weekly.Name(2026, 1, 98), 2026, 1, 98), + newWeeklyTestRecord(weekly.Name(2026, 1, 99), 2026, 1, 99), + newWeeklyTestRecord(weekly.Name(2026, 2, 50), 2026, 2, 50), + newWeeklyTestRecord(weekly.Name(2026, 3, 99), 2026, 3, 99), {Name: "Week 3-2026 - #99", GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew}, } + for _, g := range games[:4] { + g.GameType = "Sudoku" + g.Mode = "Easy" + g.InitialState = "{}" + g.SaveState = "{}" + g.Status = StatusNew + } for _, g := range games { if err := s.CreateGame(g); err != nil { t.Fatal(err) @@ -322,10 +353,17 @@ func TestGetCompletedWeeklyGauntlets(t *testing.T) { func TestGetCurrentWeeklyHighestCompletedIndex(t *testing.T) { s := openTestStore(t) games := []*GameRecord{ - {Name: weekly.Name(2026, 10, 1), GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew}, - {Name: weekly.Name(2026, 10, 17), GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew}, - {Name: weekly.Name(2026, 10, 9), GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew}, - {Name: weekly.Name(2026, 11, 30), GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew}, + newWeeklyTestRecord(weekly.Name(2026, 10, 1), 2026, 10, 1), + newWeeklyTestRecord(weekly.Name(2026, 10, 17), 2026, 10, 17), + newWeeklyTestRecord(weekly.Name(2026, 10, 9), 2026, 10, 9), + newWeeklyTestRecord(weekly.Name(2026, 11, 30), 2026, 11, 30), + } + for _, g := range games { + g.GameType = "Sudoku" + g.Mode = "Easy" + g.InitialState = "{}" + g.SaveState = "{}" + g.Status = StatusNew } for _, g := range games { if err := s.CreateGame(g); err != nil { diff --git a/store/store.go b/store/store.go index 3153294..affc14e 100644 --- a/store/store.go +++ b/store/store.go @@ -163,39 +163,11 @@ func Open(dbPath string) (*Store, error) { return nil, fmt.Errorf("enabling WAL mode: %w", err) } - if _, err := db.Exec(createTableSQL); err != nil { - db.Close() - return nil, fmt.Errorf("creating table: %w", err) - } - - if err := ensureGameColumns(db); err != nil { - db.Close() - return nil, err - } - if err := backfillGameMetadata(db); err != nil { + if err := runMigrations(db); err != nil { db.Close() return nil, err } - if _, err := db.Exec(`DROP VIEW IF EXISTS category_stats`); err != nil { - db.Close() - return nil, fmt.Errorf("dropping category_stats view: %w", err) - } - if _, err := db.Exec(`DROP VIEW IF EXISTS mode_stats`); err != nil { - db.Close() - return nil, fmt.Errorf("dropping mode_stats view: %w", err) - } - - if _, err := db.Exec(createCategoryStatsViewSQL); err != nil { - db.Close() - return nil, fmt.Errorf("creating category_stats view: %w", err) - } - - if _, err := db.Exec(createModeStatsViewSQL); err != nil { - db.Close() - return nil, fmt.Errorf("creating mode_stats view: %w", err) - } - return &Store{db: db}, nil } @@ -208,20 +180,7 @@ func (s *Store) CreateGame(rec *GameRecord) error { rec.ModeID = CanonicalModeID(rec.Mode) } if rec.RunKind == "" { - rec.RunKind = RunKindForName(rec.Name) - } - if rec.RunDate == nil { - rec.RunDate = RunDateForName(rec.Name) - } - if rec.SeedText == "" { - rec.SeedText = SeedTextForName(rec.Name) - } - if rec.WeekYear == 0 || rec.WeekNumber == 0 || rec.WeekIndex == 0 { - if year, week, index, ok := WeeklyIdentityForName(rec.Name); ok { - rec.WeekYear = year - rec.WeekNumber = week - rec.WeekIndex = index - } + rec.RunKind = RunKindNormal } result, err := s.db.Exec( diff --git a/store/store_test.go b/store/store_test.go index a41cb13..f2bde54 100644 --- a/store/store_test.go +++ b/store/store_test.go @@ -1,6 +1,7 @@ package store import ( + "database/sql" "path/filepath" "testing" "time" @@ -26,9 +27,27 @@ func newTestRecord(name string) *GameRecord { InitialState: `{"grid":"..."}`, SaveState: `{"grid":"..."}`, Status: StatusNew, + RunKind: RunKindNormal, } } +func newDailyTestRecord(name string, date time.Time) *GameRecord { + rec := newTestRecord(name) + day := dayOnly(date) + rec.RunKind = RunKindDaily + rec.RunDate = &day + return rec +} + +func newWeeklyTestRecord(name string, year, weekNumber, index int) *GameRecord { + rec := newTestRecord(name) + rec.RunKind = RunKindWeekly + rec.WeekYear = year + rec.WeekNumber = weekNumber + rec.WeekIndex = index + return rec +} + func TestOpen(t *testing.T) { t.Run("creates directory and database", func(t *testing.T) { dir := t.TempDir() @@ -68,6 +87,101 @@ func TestOpen(t *testing.T) { t.Fatal("expected record to persist across reopen") } }) + + t.Run("records current schema version", func(t *testing.T) { + s := openTestStore(t) + + version, err := schemaVersion(s.db) + if err != nil { + t.Fatal(err) + } + if got, want := version, currentSchemaVersion; got != want { + t.Fatalf("schema version = %d, want %d", got, want) + } + }) + + t.Run("upgrades legacy databases and backfills metadata", func(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "legacy.db") + + raw, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { raw.Close() }) + + _, err = raw.Exec(` +CREATE TABLE games ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + game_type TEXT NOT NULL, + mode TEXT NOT NULL, + initial_state TEXT NOT NULL, + save_state TEXT, + status TEXT NOT NULL DEFAULT 'new' + CHECK(status IN ('new','in_progress','completed','abandoned')), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + completed_at DATETIME +);`) + if err != nil { + t.Fatal(err) + } + + _, err = raw.Exec(` +INSERT INTO games (name, game_type, mode, initial_state, save_state, status) +VALUES (?, ?, ?, ?, ?, ?)`, + "Daily Feb 16 26 - amber-fox", + "Sudoku", + "Easy", + `{"grid":"initial"}`, + `{"grid":"save"}`, + string(StatusNew), + ) + if err != nil { + t.Fatal(err) + } + if err := raw.Close(); err != nil { + t.Fatal(err) + } + + s, err := Open(dbPath) + if err != nil { + t.Fatal(err) + } + defer s.Close() + + version, err := schemaVersion(s.db) + if err != nil { + t.Fatal(err) + } + if got, want := version, currentSchemaVersion; got != want { + t.Fatalf("schema version = %d, want %d", got, want) + } + + rec, err := s.GetGameByName("Daily Feb 16 26 - amber-fox") + if err != nil { + t.Fatal(err) + } + if rec == nil { + t.Fatal("expected migrated record") + } + if got, want := rec.GameID, "sudoku"; got != want { + t.Fatalf("GameID = %q, want %q", got, want) + } + if got, want := rec.ModeID, "easy"; got != want { + t.Fatalf("ModeID = %q, want %q", got, want) + } + if got, want := rec.RunKind, RunKindDaily; got != want { + t.Fatalf("RunKind = %q, want %q", got, want) + } + if rec.RunDate == nil { + t.Fatal("expected RunDate to be backfilled") + } + + if _, err := s.GetCategoryStats(); err != nil { + t.Fatalf("GetCategoryStats() error after migration: %v", err) + } + }) } func TestCreateGame(t *testing.T) { @@ -106,6 +220,7 @@ func TestCreateGame(t *testing.T) { InitialState: `{"init":true}`, SaveState: `{"save":true}`, Status: StatusNew, + RunKind: RunKindNormal, } if err := s.CreateGame(rec); err != nil { t.Fatal(err) @@ -149,6 +264,34 @@ func TestCreateGame(t *testing.T) { t.Fatal("expected error for duplicate name") } }) + + t.Run("does not infer deterministic metadata from name", func(t *testing.T) { + s := openTestStore(t) + rec := newTestRecord("Daily Feb 16 26 - amber-fox") + if err := s.CreateGame(rec); err != nil { + t.Fatal(err) + } + + got, err := s.GetGameByName(rec.Name) + if err != nil { + t.Fatal(err) + } + if got == nil { + t.Fatal("expected record") + } + if got.RunKind != RunKindNormal { + t.Fatalf("RunKind = %q, want %q", got.RunKind, RunKindNormal) + } + if got.RunDate != nil { + t.Fatalf("RunDate = %v, want nil", got.RunDate) + } + if got.SeedText != "" { + t.Fatalf("SeedText = %q, want empty", got.SeedText) + } + if got.WeekYear != 0 || got.WeekNumber != 0 || got.WeekIndex != 0 { + t.Fatalf("weekly metadata = (%d, %d, %d), want zero values", got.WeekYear, got.WeekNumber, got.WeekIndex) + } + }) } func TestUpdateSaveState(t *testing.T) { @@ -511,7 +654,7 @@ func TestGetGameByName(t *testing.T) { func TestGetDeterministicGame(t *testing.T) { s := openTestStore(t) - rec := newTestRecord("Week 01-2026 - #01") + rec := newWeeklyTestRecord("Week 01-2026 - #01", 2026, 1, 1) if err := s.CreateGame(rec); err != nil { t.Fatal(err) } @@ -535,9 +678,9 @@ func TestWeeklyQueries(t *testing.T) { s := openTestStore(t) records := []*GameRecord{ - newTestRecord(weekly.Name(2026, 1, 1)), - newTestRecord(weekly.Name(2026, 1, 2)), - newTestRecord(weekly.Name(2026, 2, 1)), + newWeeklyTestRecord(weekly.Name(2026, 1, 1), 2026, 1, 1), + newWeeklyTestRecord(weekly.Name(2026, 1, 2), 2026, 1, 2), + newWeeklyTestRecord(weekly.Name(2026, 2, 1), 2026, 2, 1), newTestRecord("Week 1-2026 - #01"), } for _, rec := range records { diff --git a/sudoku/README.md b/sudoku/README.md index 5d9c552..f413e6e 100644 --- a/sudoku/README.md +++ b/sudoku/README.md @@ -21,7 +21,6 @@ in red. | `Backspace` | Clear cell | | `Ctrl+R` | Reset puzzle | | `Ctrl+H` | Toggle full help | -| `Ctrl+E` | Toggle debug overlay | | `Escape` | Return to main menu | ## Modes diff --git a/sudoku/Export.go b/sudoku/export.go similarity index 100% rename from sudoku/Export.go rename to sudoku/export.go diff --git a/sudoku/Gamemode.go b/sudoku/gamemode.go similarity index 64% rename from sudoku/Gamemode.go rename to sudoku/gamemode.go index 702947f..2d6fef0 100644 --- a/sudoku/Gamemode.go +++ b/sudoku/gamemode.go @@ -4,8 +4,9 @@ import ( _ "embed" "math/rand/v2" - "charm.land/bubbles/v2/list" "github.com/FelineStateMachine/puzzletea/game" + "github.com/FelineStateMachine/puzzletea/gameentry" + "github.com/FelineStateMachine/puzzletea/puzzle" ) //go:embed help.md @@ -37,7 +38,7 @@ func (s SudokuMode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) { return New(s, GenerateProvidedCellsSeeded(s, rng)) } -var Modes = []list.Item{ +var Modes = []game.Mode{ NewMode("Beginner", "45–52 clues. Single Candidate / Scanning.", 45), NewMode("Easy", "38–44 clues. Naked Singles.", 38), NewMode("Medium", "32–37 clues. Hidden Pairs / Pointing.", 32), @@ -46,16 +47,19 @@ var Modes = []list.Item{ NewMode("Diabolical", "17–21 clues. Swordfish / XY-Chains.", 17), } -var DailyModes = []list.Item{ - Modes[1], // Easy - Modes[2], // Medium -} +var ModeDefinitions = gameentry.BuildModeDefs(Modes) -var Definition = game.Definition{ - Name: "Sudoku", - Description: "Fill the 9x9 grid following sudoku rules.", - Modes: Modes, - DailyModes: DailyModes, - Help: HelpContent, - Import: func(data []byte) (game.Gamer, error) { return ImportModel(data) }, -} +var Definition = puzzle.NewDefinition(puzzle.DefinitionSpec{ + Name: "Sudoku", + Description: "Fill the 9x9 grid following sudoku rules.", + Modes: ModeDefinitions, + DailyModeIDs: puzzle.SelectModeIDsByIndex(ModeDefinitions, 1, 2), +}) + +var Entry = gameentry.NewEntry(gameentry.EntrySpec{ + Definition: Definition, + Help: HelpContent, + Import: game.AdaptImport(ImportModel), + Modes: Modes, + Print: PDFPrintAdapter, +}) diff --git a/sudoku/help.md b/sudoku/help.md index 0c799b0..d175a95 100644 --- a/sudoku/help.md +++ b/sudoku/help.md @@ -18,7 +18,7 @@ Fill a 9x9 grid with digits so every row, column, and box is complete. | `Mouse left-click` | Focus a cell | | `1`-`9` | Fill cell with digit | | `Backspace` | Clear cell | -| `Ctrl+H` | Toggle help bar | +| `Ctrl+H` | Toggle full help | | `Ctrl+R` | Reset puzzle | | `Escape` | Return to main menu | diff --git a/sudoku/Model.go b/sudoku/model.go similarity index 100% rename from sudoku/Model.go rename to sudoku/model.go diff --git a/sudoku/PrintAdapter.go b/sudoku/print_adapter.go similarity index 97% rename from sudoku/PrintAdapter.go rename to sudoku/print_adapter.go index 66a311a..7d54657 100644 --- a/sudoku/PrintAdapter.go +++ b/sudoku/print_adapter.go @@ -9,6 +9,8 @@ import ( type printAdapter struct{} +var PDFPrintAdapter = printAdapter{} + func (printAdapter) CanonicalGameType() string { return "Sudoku" } func (printAdapter) Aliases() []string { return []string{"sudoku"} } @@ -105,7 +107,3 @@ func drawSudokuGivens(pdf *fpdf.Fpdf, startX, startY, cellSize float64, givens [ } } } - -func init() { - pdfexport.RegisterPrintAdapter(printAdapter{}) -} diff --git a/sudokurgb/README.md b/sudokurgb/README.md index 1a7650f..86e21c1 100644 --- a/sudokurgb/README.md +++ b/sudokurgb/README.md @@ -29,7 +29,6 @@ three copies of a value. | `Backspace` | Clear cell | | `Ctrl+R` | Reset puzzle | | `Ctrl+H` | Toggle full help | -| `Ctrl+E` | Toggle debug overlay | | `Escape` | Return to main menu | ## Modes diff --git a/sudokurgb/Export.go b/sudokurgb/export.go similarity index 100% rename from sudokurgb/Export.go rename to sudokurgb/export.go diff --git a/sudokurgb/Gamemode.go b/sudokurgb/gamemode.go similarity index 60% rename from sudokurgb/Gamemode.go rename to sudokurgb/gamemode.go index 2a5f5ea..52c0895 100644 --- a/sudokurgb/Gamemode.go +++ b/sudokurgb/gamemode.go @@ -4,8 +4,9 @@ import ( _ "embed" "math/rand/v2" - "charm.land/bubbles/v2/list" "github.com/FelineStateMachine/puzzletea/game" + "github.com/FelineStateMachine/puzzletea/gameentry" + "github.com/FelineStateMachine/puzzletea/puzzle" ) //go:embed help.md @@ -37,7 +38,7 @@ func (s SudokuRGBMode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) { return New(s, GenerateProvidedCellsSeeded(s, rng)) } -var Modes = []list.Item{ +var Modes = []game.Mode{ NewMode("Beginner", "60 clues. Gentle intro to RGB quota logic.", 60), NewMode("Easy", "54 clues. Early rows and boxes resolve quickly.", 54), NewMode("Medium", "48 clues. Mixed row, column, and box pressure.", 48), @@ -46,17 +47,20 @@ var Modes = []list.Item{ NewMode("Diabolical", "30 clues. Tightest clue budget in the launch set.", 30), } -var DailyModes = []list.Item{ - Modes[1], // Easy - Modes[2], // Medium -} +var ModeDefinitions = gameentry.BuildModeDefs(Modes) -var Definition = game.Definition{ - Name: "Sudoku RGB", - Description: "Fill the board with RGB symbols so each row, column, and 3x3 box contains {1,1,1,2,2,2,3,3,3}. [1,2,3] maps to [▲,■,●]. Inspired by Sudoku Ripeto.", - Aliases: []string{"rgb sudoku", "ripeto", "sudoku ripeto"}, - Modes: Modes, - DailyModes: DailyModes, - Help: HelpContent, - Import: func(data []byte) (game.Gamer, error) { return ImportModel(data) }, -} +var Definition = puzzle.NewDefinition(puzzle.DefinitionSpec{ + Name: "Sudoku RGB", + Description: "Fill the board with RGB symbols so each row, column, and 3x3 box contains {1,1,1,2,2,2,3,3,3}. [1,2,3] maps to [▲,■,●]. Inspired by Sudoku Ripeto.", + Aliases: []string{"rgb sudoku", "ripeto", "sudoku ripeto"}, + Modes: ModeDefinitions, + DailyModeIDs: puzzle.SelectModeIDsByIndex(ModeDefinitions, 1, 2), +}) + +var Entry = gameentry.NewEntry(gameentry.EntrySpec{ + Definition: Definition, + Help: HelpContent, + Import: game.AdaptImport(ImportModel), + Modes: Modes, + Print: PDFPrintAdapter, +}) diff --git a/sudokurgb/help.md b/sudokurgb/help.md index 9484105..0a17a55 100644 --- a/sudokurgb/help.md +++ b/sudokurgb/help.md @@ -22,7 +22,7 @@ Inspired by Ripeto. | `Mouse left-click` | Focus a cell | | `1` / `2` / `3` | Fill cell with `▲` / `■` / `●` | | `Backspace` | Clear cell | -| `Ctrl+H` | Toggle help bar | +| `Ctrl+H` | Toggle full help | | `Ctrl+R` | Reset puzzle | | `Escape` | Return to main menu | diff --git a/sudokurgb/Model.go b/sudokurgb/model.go similarity index 100% rename from sudokurgb/Model.go rename to sudokurgb/model.go diff --git a/sudokurgb/PrintAdapter.go b/sudokurgb/print_adapter.go similarity index 97% rename from sudokurgb/PrintAdapter.go rename to sudokurgb/print_adapter.go index b9b679e..3dafc36 100644 --- a/sudokurgb/PrintAdapter.go +++ b/sudokurgb/print_adapter.go @@ -9,6 +9,8 @@ import ( type printAdapter struct{} +var PDFPrintAdapter = printAdapter{} + func (printAdapter) CanonicalGameType() string { return "Sudoku RGB" } func (printAdapter) Aliases() []string { return []string{"sudoku rgb", "rgb sudoku", "ripeto", "sudoku ripeto"} @@ -107,7 +109,3 @@ func drawSudokuRGBGivens(pdf *fpdf.Fpdf, startX, startY, cellSize float64, given } } } - -func init() { - pdfexport.RegisterPrintAdapter(printAdapter{}) -} diff --git a/takuzu/README.md b/takuzu/README.md index 09bad93..6fe2aaf 100644 --- a/takuzu/README.md +++ b/takuzu/README.md @@ -27,7 +27,6 @@ every cell is filled and all three rules are satisfied. | `Backspace` | Clear cell | | `Ctrl+R` | Reset puzzle | | `Ctrl+H` | Toggle full help | -| `Ctrl+E` | Toggle debug overlay | | `Escape` | Return to main menu | ## Modes diff --git a/takuzu/Export.go b/takuzu/export.go similarity index 100% rename from takuzu/Export.go rename to takuzu/export.go diff --git a/takuzu/Gamemode.go b/takuzu/gamemode.go similarity index 69% rename from takuzu/Gamemode.go rename to takuzu/gamemode.go index 1143a41..d74017f 100644 --- a/takuzu/Gamemode.go +++ b/takuzu/gamemode.go @@ -4,8 +4,9 @@ import ( _ "embed" "math/rand/v2" - "charm.land/bubbles/v2/list" "github.com/FelineStateMachine/puzzletea/game" + "github.com/FelineStateMachine/puzzletea/gameentry" + "github.com/FelineStateMachine/puzzletea/puzzle" ) //go:embed help.md @@ -43,7 +44,7 @@ func (t TakuzuMode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) { return New(t, puzzle, provided) } -var Modes = []list.Item{ +var Modes = []game.Mode{ NewMode("Beginner", "6×6 grid, ~50% clues. Doubles and sandwich patterns.", 6, 0.50), NewMode("Easy", "6×6 grid, ~40% clues. Counting required.", 6, 0.40), NewMode("Medium", "8×8 grid, ~40% clues. Larger grid, moderate deduction.", 8, 0.40), @@ -53,17 +54,20 @@ var Modes = []list.Item{ NewMode("Extreme", "14×14 grid, ~28% clues. Maximum challenge.", 14, 0.28), } -var DailyModes = []list.Item{ - Modes[2], // Medium 8x8 - Modes[3], // Tricky 10x10 -} +var ModeDefinitions = gameentry.BuildModeDefs(Modes) -var Definition = game.Definition{ - Name: "Takuzu", - Description: "Fill the grid with ● and ○. No 3 in a row.", - Aliases: []string{"binairo", "binary"}, - Modes: Modes, - DailyModes: DailyModes, - Help: HelpContent, - Import: func(data []byte) (game.Gamer, error) { return ImportModel(data) }, -} +var Definition = puzzle.NewDefinition(puzzle.DefinitionSpec{ + Name: "Takuzu", + Description: "Fill the grid with ● and ○. No 3 in a row.", + Aliases: []string{"binairo", "binary"}, + Modes: ModeDefinitions, + DailyModeIDs: puzzle.SelectModeIDsByIndex(ModeDefinitions, 2, 3), +}) + +var Entry = gameentry.NewEntry(gameentry.EntrySpec{ + Definition: Definition, + Help: HelpContent, + Import: game.AdaptImport(ImportModel), + Modes: Modes, + Print: PDFPrintAdapter, +}) diff --git a/takuzu/help.md b/takuzu/help.md index 7309c9c..c751501 100644 --- a/takuzu/help.md +++ b/takuzu/help.md @@ -24,7 +24,7 @@ error chip if a line goes over quota. | `z` / `0` | Place ● (filled) | | `x` / `1` | Place ○ (open) | | `Backspace` | Clear cell | -| `Ctrl+H` | Toggle help bar | +| `Ctrl+H` | Toggle full help | | `Ctrl+R` | Reset puzzle | | `Escape` | Return to main menu | diff --git a/takuzu/Model.go b/takuzu/model.go similarity index 100% rename from takuzu/Model.go rename to takuzu/model.go diff --git a/takuzu/PrintAdapter.go b/takuzu/print_adapter.go similarity index 98% rename from takuzu/PrintAdapter.go rename to takuzu/print_adapter.go index 578b178..4635abe 100644 --- a/takuzu/PrintAdapter.go +++ b/takuzu/print_adapter.go @@ -9,6 +9,8 @@ import ( type printAdapter struct{} +var PDFPrintAdapter = printAdapter{} + var defaultTakuzuRules = []string{ "No three equal adjacent in any row or column.", "Each row/column has equal 0 and 1 counts, and rows/columns are unique.", @@ -196,7 +198,3 @@ func takuzuRelationFontSize(cellSize float64, size int) float64 { func takuzuRelationBackdropSize(cellSize, fontSize float64) float64 { return fontSize + cellSize*0.12 } - -func init() { - pdfexport.RegisterPrintAdapter(printAdapter{}) -} diff --git a/takuzuplus/README.md b/takuzuplus/README.md index 340282d..5473cb4 100644 --- a/takuzuplus/README.md +++ b/takuzuplus/README.md @@ -2,8 +2,6 @@ Fill the grid with two symbols while obeying the normal Takuzu rules plus fixed relation clues. -![Takuzu+ gameplay](../vhs/takuzu.gif) - ## How to Play Fill every empty cell with either a filled circle (●) or an open circle (○) @@ -29,7 +27,6 @@ cell is filled and all four rules are satisfied. | `Backspace` | Clear cell | | `Ctrl+R` | Reset puzzle | | `Ctrl+H` | Toggle full help | -| `Ctrl+E` | Toggle debug overlay | | `Escape` | Return to main menu | ## Modes diff --git a/takuzuplus/Export.go b/takuzuplus/export.go similarity index 100% rename from takuzuplus/Export.go rename to takuzuplus/export.go diff --git a/takuzuplus/Gamemode.go b/takuzuplus/gamemode.go similarity index 71% rename from takuzuplus/Gamemode.go rename to takuzuplus/gamemode.go index c752c2b..de6751a 100644 --- a/takuzuplus/Gamemode.go +++ b/takuzuplus/gamemode.go @@ -4,8 +4,9 @@ import ( _ "embed" "math/rand/v2" - "charm.land/bubbles/v2/list" "github.com/FelineStateMachine/puzzletea/game" + "github.com/FelineStateMachine/puzzletea/gameentry" + "github.com/FelineStateMachine/puzzletea/puzzle" ) //go:embed help.md @@ -47,7 +48,7 @@ func (t TakuzuPlusMode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) { var modeProfiles = relationProfiles() -var Modes = []list.Item{ +var Modes = []game.Mode{ NewMode("Beginner", "6×6 grid, relation clues ease early logic.", 6, 0.50, modeProfiles[0]), NewMode("Easy", "6×6 grid with additive = and x clues.", 6, 0.40, modeProfiles[1]), NewMode("Medium", "8×8 grid, mixed Takuzu and relation deductions.", 8, 0.40, modeProfiles[2]), @@ -57,17 +58,20 @@ var Modes = []list.Item{ NewMode("Extreme", "14×14 grid, maximum size with additive relation clues.", 14, 0.28, modeProfiles[6]), } -var DailyModes = []list.Item{ - Modes[2], - Modes[3], -} +var ModeDefinitions = gameentry.BuildModeDefs(Modes) -var Definition = game.Definition{ - Name: "Takuzu+", - Description: "Fill the grid with ● and ○ using some relational clues. No 3 in a row.", - Aliases: []string{"takuzu plus", "binario+", "binario plus"}, - Modes: Modes, - DailyModes: DailyModes, - Help: HelpContent, - Import: func(data []byte) (game.Gamer, error) { return ImportModel(data) }, -} +var Definition = puzzle.NewDefinition(puzzle.DefinitionSpec{ + Name: "Takuzu+", + Description: "Fill the grid with ● and ○ using some relational clues. No 3 in a row.", + Aliases: []string{"takuzu plus", "binario+", "binario plus"}, + Modes: ModeDefinitions, + DailyModeIDs: puzzle.SelectModeIDsByIndex(ModeDefinitions, 2, 3), +}) + +var Entry = gameentry.NewEntry(gameentry.EntrySpec{ + Definition: Definition, + Help: HelpContent, + Import: game.AdaptImport(ImportModel), + Modes: Modes, + Print: PDFPrintAdapter, +}) diff --git a/takuzuplus/help.md b/takuzuplus/help.md index 81d552d..4836bd0 100644 --- a/takuzuplus/help.md +++ b/takuzuplus/help.md @@ -24,6 +24,6 @@ error chip if a line goes over quota. | `z` / `0` | Place ● (filled) | | `x` / `1` | Place ○ (open) | | `Backspace` | Clear cell | -| `Ctrl+H` | Toggle help bar | +| `Ctrl+H` | Toggle full help | | `Ctrl+R` | Reset puzzle | | `Escape` | Return to main menu | diff --git a/takuzuplus/Model.go b/takuzuplus/model.go similarity index 100% rename from takuzuplus/Model.go rename to takuzuplus/model.go diff --git a/takuzuplus/PrintAdapter.go b/takuzuplus/print_adapter.go similarity index 93% rename from takuzuplus/PrintAdapter.go rename to takuzuplus/print_adapter.go index 8fb41d4..ea8178f 100644 --- a/takuzuplus/PrintAdapter.go +++ b/takuzuplus/print_adapter.go @@ -8,6 +8,8 @@ import ( type printAdapter struct{} +var PDFPrintAdapter = printAdapter{} + var takuzuPlusRules = []string{ "No three equal adjacent in any row or column.", "Each row/column has equal 0 and 1 counts, and rows/columns are unique.", @@ -28,7 +30,3 @@ func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error { } return nil } - -func init() { - pdfexport.RegisterPrintAdapter(printAdapter{}) -} diff --git a/ui/mainmenu.go b/ui/mainmenu.go index 8bfd2c8..41449a1 100644 --- a/ui/mainmenu.go +++ b/ui/mainmenu.go @@ -43,6 +43,10 @@ func (m MainMenu) Selected() MenuItem { return m.items[m.cursor] } +func (m MainMenu) SelectedAction() string { + return m.Selected().ActionID() +} + // View renders the main menu as a framed panel with logo and items. func (m MainMenu) View() string { styledLogo := LogoStyle().Render(logo) diff --git a/ui/menuitem.go b/ui/menuitem.go index aeb4ae9..b206e51 100644 --- a/ui/menuitem.go +++ b/ui/menuitem.go @@ -4,8 +4,16 @@ package ui type MenuItem struct { ItemTitle string Desc string + Action string } func (i MenuItem) Title() string { return i.ItemTitle } func (i MenuItem) Description() string { return i.Desc } func (i MenuItem) FilterValue() string { return i.ItemTitle } + +func (i MenuItem) ActionID() string { + if i.Action != "" { + return i.Action + } + return i.ItemTitle +} diff --git a/ui/style.go b/ui/style.go index 4d46ad1..779146c 100644 --- a/ui/style.go +++ b/ui/style.go @@ -15,6 +15,15 @@ func DebugStyle() lipgloss.Style { BorderForeground(theme.Current().Error) } +// ErrorNoticeStyle returns the style for user-visible error notices. +func ErrorNoticeStyle() lipgloss.Style { + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(theme.Current().Error). + Foreground(theme.Current().Error). + Padding(0, 1) +} + // LogoStyle returns the style for the ASCII art brand logo. func LogoStyle() lipgloss.Style { p := theme.Current() diff --git a/ui/table.go b/ui/table.go index 4ad1251..344ef24 100644 --- a/ui/table.go +++ b/ui/table.go @@ -13,6 +13,21 @@ import ( // MaxTableRows is the maximum number of rows visible in the continue table. const MaxTableRows = 20 +const ( + continueTableWidth = 104 + weeklyTableWidth = 71 +) + +// ContinueTableWidth is the natural width of the saved-games table. +func ContinueTableWidth() int { + return continueTableWidth +} + +// WeeklyTableWidth is the natural width of the weekly browser table. +func WeeklyTableWidth() int { + return weeklyTableWidth +} + // FormatStatus converts a GameStatus enum to a human-readable display string. func FormatStatus(s store.GameStatus) string { switch s { @@ -55,19 +70,12 @@ func InitContinueTable(s *store.Store, height int) (table.Model, []store.GameRec } } - tableWidth := 0 - for _, c := range columns { - tableWidth += c.Width - } - // Account for column gaps (2 chars between each column). - tableWidth += (len(columns) - 1) * 2 - visibleRows := min(len(rows), MaxTableRows) t := table.New( table.WithColumns(columns), table.WithRows(rows), table.WithFocused(true), - table.WithWidth(tableWidth), + table.WithWidth(continueTableWidth), table.WithHeight(min(max(height-2, 1), visibleRows)), ) t.SetStyles(defaultTableStyles()) @@ -85,18 +93,12 @@ func InitWeeklyTable(rows []table.Row, height int) table.Model { {Title: "Status", Width: 12}, } - tableWidth := 0 - for _, c := range columns { - tableWidth += c.Width - } - tableWidth += (len(columns) - 1) * 2 - tableHeight := min(max(height-2, 1), MaxTableRows) t := table.New( table.WithColumns(columns), table.WithRows(rows), table.WithFocused(true), - table.WithWidth(tableWidth), + table.WithWidth(weeklyTableWidth), table.WithHeight(tableHeight), ) t.SetStyles(defaultTableStyles()) diff --git a/ui/ui_test.go b/ui/ui_test.go index 114a166..1e29aca 100644 --- a/ui/ui_test.go +++ b/ui/ui_test.go @@ -36,6 +36,22 @@ func TestMenuItemFilterValue(t *testing.T) { } } +func TestMenuItemActionID(t *testing.T) { + t.Run("uses stable action when present", func(t *testing.T) { + item := MenuItem{ItemTitle: "Generate", Action: "create"} + if got := item.ActionID(); got != "create" { + t.Fatalf("ActionID() = %q, want %q", got, "create") + } + }) + + t.Run("falls back to title for legacy callers", func(t *testing.T) { + item := MenuItem{ItemTitle: "Generate"} + if got := item.ActionID(); got != "Generate" { + t.Fatalf("ActionID() = %q, want %q", got, "Generate") + } + }) +} + // --- FormatStatus (P0) --- func TestFormatStatus(t *testing.T) { diff --git a/weekly/weekly.go b/weekly/weekly.go index d6a13da..e014ada 100644 --- a/weekly/weekly.go +++ b/weekly/weekly.go @@ -10,14 +10,10 @@ import ( "github.com/FelineStateMachine/puzzletea/game" "github.com/FelineStateMachine/puzzletea/registry" + "github.com/FelineStateMachine/puzzletea/schedule" ) -// Entry pairs a SeededSpawner with metadata for the eligible weekly pool. -type Entry struct { - Spawner game.SeededSpawner - GameType string - Mode string -} +type Entry = schedule.Entry // Info identifies a single weekly gauntlet puzzle. type Info struct { @@ -33,16 +29,7 @@ var ( ) func buildEligibleModes() []Entry { - registryEntries := registry.DailyEntries() - entries := make([]Entry, 0, len(registryEntries)) - for _, entry := range registryEntries { - entries = append(entries, Entry{ - Spawner: entry.Spawner, - GameType: entry.GameType, - Mode: entry.Mode, - }) - } - return entries + return schedule.BuildEligibleModes(registry.DailyEntries()) } // Name returns the canonical persisted name for a weekly puzzle. @@ -134,23 +121,7 @@ func Mode(year, week, index int) (game.SeededSpawner, string, string) { } seedName := Name(year, week, index) - var best Entry - var bestHash uint64 - found := false - for _, entry := range eligibleModes { - h := fnv.New64a() - h.Write([]byte(seedName)) - h.Write([]byte{0}) - h.Write([]byte(entry.GameType)) - h.Write([]byte{0}) - h.Write([]byte(entry.Mode)) - score := h.Sum64() - if !found || score > bestHash { - bestHash = score - best = entry - found = true - } - } + best, found := schedule.SelectBySeed(seedName, eligibleModes) if !found { return nil, "", "" } diff --git a/weekly/weekly_test.go b/weekly/weekly_test.go index 66e51bd..a41d75a 100644 --- a/weekly/weekly_test.go +++ b/weekly/weekly_test.go @@ -60,41 +60,6 @@ func TestModeDeterministic(t *testing.T) { } } -func TestModeStableOnPoolChange(t *testing.T) { - type selection struct { - gameType string - mode string - } - - selections := make([]selection, 0, 20) - for index := 1; index <= 20; index++ { - _, gameType, mode := Mode(2026, 1, index) - selections = append(selections, selection{gameType: gameType, mode: mode}) - } - - synth := Entry{ - Spawner: eligibleModes[0].Spawner, - GameType: "SyntheticGame", - Mode: "SyntheticMode", - } - eligibleModes = append(eligibleModes, synth) - defer func() { - eligibleModes = eligibleModes[:len(eligibleModes)-1] - }() - - for index := 1; index <= 20; index++ { - _, gameType, mode := Mode(2026, 1, index) - if gameType == synth.GameType && mode == synth.Mode { - continue - } - want := selections[index-1] - if gameType != want.gameType || mode != want.mode { - t.Fatalf("index %d selection changed from (%q,%q) to (%q,%q)", - index, want.gameType, want.mode, gameType, mode) - } - } -} - func TestISOWeekBoundary(t *testing.T) { date := time.Date(2025, time.December, 29, 12, 0, 0, 0, time.UTC) info := Current(date) diff --git a/wordsearch/README.md b/wordsearch/README.md index ea5b25b..191df40 100644 --- a/wordsearch/README.md +++ b/wordsearch/README.md @@ -24,7 +24,6 @@ The puzzle is solved when all words are found. | `Backspace` | Cancel current selection | | `Ctrl+R` | Reset puzzle | | `Ctrl+H` | Toggle full help | -| `Ctrl+E` | Toggle debug overlay | | `Escape` | Return to main menu | ## Modes diff --git a/wordsearch/Export.go b/wordsearch/export.go similarity index 100% rename from wordsearch/Export.go rename to wordsearch/export.go diff --git a/wordsearch/Gamemode.go b/wordsearch/gamemode.go similarity index 70% rename from wordsearch/Gamemode.go rename to wordsearch/gamemode.go index 0192735..2722d9e 100644 --- a/wordsearch/Gamemode.go +++ b/wordsearch/gamemode.go @@ -4,8 +4,9 @@ import ( _ "embed" "math/rand/v2" - "charm.land/bubbles/v2/list" "github.com/FelineStateMachine/puzzletea/game" + "github.com/FelineStateMachine/puzzletea/gameentry" + "github.com/FelineStateMachine/puzzletea/puzzle" ) //go:embed help.md @@ -49,22 +50,26 @@ func (w WordSearchMode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) { return New(w, grid, words) } -var Modes = []list.Item{ +var Modes = []game.Mode{ NewMode("Easy 10x10", "Find 6 words in a 10x10 grid.", 10, 10, 6, 3, 5, []Direction{Right, Down, DownRight}), NewMode("Medium 15x15", "Find 10 words in a 15x15 grid.", 15, 15, 10, 4, 7, []Direction{Right, Down, DownRight, DownLeft, Left, Up}), NewMode("Hard 20x20", "Find 15 words in a 20x20 grid.", 20, 20, 15, 5, 10, []Direction{Right, Down, DownRight, DownLeft, Left, Up, UpRight, UpLeft}), } -var DailyModes = []list.Item{ - Modes[0], // Easy 10x10 -} +var ModeDefinitions = gameentry.BuildModeDefs(Modes) -var Definition = game.Definition{ - Name: "Word Search", - Description: "Find the hidden words in a letter grid.", - Aliases: []string{"words", "wordsearch", "ws"}, - Modes: Modes, - DailyModes: DailyModes, - Help: HelpContent, - Import: func(data []byte) (game.Gamer, error) { return ImportModel(data) }, -} +var Definition = puzzle.NewDefinition(puzzle.DefinitionSpec{ + Name: "Word Search", + Description: "Find the hidden words in a letter grid.", + Aliases: []string{"words", "wordsearch", "ws"}, + Modes: ModeDefinitions, + DailyModeIDs: puzzle.SelectModeIDsByIndex(ModeDefinitions, 0), +}) + +var Entry = gameentry.NewEntry(gameentry.EntrySpec{ + Definition: Definition, + Help: HelpContent, + Import: game.AdaptImport(ImportModel), + Modes: Modes, + Print: PDFPrintAdapter, +}) diff --git a/wordsearch/help.md b/wordsearch/help.md index 31c6909..bfca5c2 100644 --- a/wordsearch/help.md +++ b/wordsearch/help.md @@ -22,7 +22,7 @@ Press `Backspace` to cancel a selection. | `Arrows` / `wasd` / `hjkl` | Move cursor | | `Enter` / `Space` | Select start or confirm end | | `Backspace` | Cancel current selection | -| `Ctrl+H` | Toggle help bar | +| `Ctrl+H` | Toggle full help | | `Ctrl+R` | Reset puzzle | | `Escape` | Return to main menu | diff --git a/wordsearch/Model.go b/wordsearch/model.go similarity index 100% rename from wordsearch/Model.go rename to wordsearch/model.go diff --git a/wordsearch/PrintAdapter.go b/wordsearch/print_adapter.go similarity index 99% rename from wordsearch/PrintAdapter.go rename to wordsearch/print_adapter.go index d3888a4..de118b5 100644 --- a/wordsearch/PrintAdapter.go +++ b/wordsearch/print_adapter.go @@ -9,6 +9,8 @@ import ( type printAdapter struct{} +var PDFPrintAdapter = printAdapter{} + func (printAdapter) CanonicalGameType() string { return "Word Search" } func (printAdapter) Aliases() []string { return []string{"word search", "wordsearch"} @@ -254,7 +256,3 @@ func minLineCountColumn(lineCounts []int) int { } return idx } - -func init() { - pdfexport.RegisterPrintAdapter(printAdapter{}) -}