- commit
- 504785ea074afcc37e053973bbdecb9eb5471480
- parent
- b6747ff1e16f74a802ae6543d2598e4a8d88c814
- author
- Dami <dami.etoile@gmail.com>
@@ -1,8 +1,6 @@
11 package app22 33 import (4- "log"5-64 "github.com/FelineStateMachine/puzzletea/game"75 sessionflow "github.com/FelineStateMachine/puzzletea/session"86 "github.com/FelineStateMachine/puzzletea/store"@@ -38,8 +36,7 @@ func (m model) importAndActivateRecord(rec store.GameRecord) (model, bool) {
3836 func (m model) importAndActivateRecordWithOptions(rec store.GameRecord, options gameOpenOptions) (model, bool) {3937 g, err := sessionflow.ImportRecord(&rec)4038 if err != nil {41- log.Printf("failed to import game: %v", err)42- return m, false39+ return m.setErrorf("Could not load saved puzzle %q: %v", rec.Name, err), false4340 }4441 4542 activeGameID := rec.ID@@ -49,5 +46,5 @@ func (m model) importAndActivateRecordWithOptions(rec store.GameRecord, options
4946 completionSaved = true5047 }5148 52- return m.activateGame(g, activeGameID, completionSaved, options), true49+ return m.activateGame(g, activeGameID, completionSaved, options).clearNotice(), true5350 }@@ -1,7 +1,6 @@
11 package app22 33 import (4- "log"54 "time"65 76 "github.com/FelineStateMachine/puzzletea/daily"@@ -16,15 +15,14 @@ func (m model) handleDailyPuzzle() (tea.Model, tea.Cmd) {
1615 1716 rec, err := m.store.GetDailyGame(name)1817 if err != nil {19- log.Printf("failed to check daily game: %v", err)20- return m, nil18+ return m.setErrorf("Could not check today’s daily puzzle: %v", err), nil2119 }2220 if rec != nil {2321 var resumed bool2422 m, resumed = m.importAndActivateRecord(*rec)2523 if resumed {2624 if err := sessionflow.ResumeAbandonedDeterministicRecord(m.store, rec); err != nil {27- log.Printf("%v", err)25+ m = m.setErrorf("%v", err)2826 }2927 }3028 return m, nil@@ -32,8 +30,7 @@ func (m model) handleDailyPuzzle() (tea.Model, tea.Cmd) {
3230 3331 spawner, gameType, modeTitle := daily.Mode(today)3432 if spawner == nil {35- log.Printf("no daily mode available for %s", today.Format("2006-01-02"))36- return m, nil33+ return m.setErrorf("No daily puzzle is configured for %s", today.Format("2006-01-02")), nil3734 }3835 3936 rng := daily.RNG(today)@@ -47,5 +44,6 @@ func (m model) handleDailyPuzzle() (tea.Model, tea.Cmd) {
4744 exitState: mainMenuView,4845 }4946 m.state = generatingView47+ m = m.clearNotice()5048 return m, tea.Batch(m.spinner.Tick, spawnSeededCmd(spawner, rng, ctx, jobID))5149 }@@ -1,8 +1,6 @@
11 package app22 33 import (4- "log"5-64 "github.com/FelineStateMachine/puzzletea/registry"75 sessionflow "github.com/FelineStateMachine/puzzletea/session"86 "github.com/FelineStateMachine/puzzletea/store"@@ -63,11 +61,13 @@ func (m model) persistCompletionIfSolved() model {
6361 saveData, err := m.session.game.GetSave()6462 if err == nil {6563 if err := m.store.UpdateSaveState(m.session.activeGameID, string(saveData)); err != nil {66- log.Printf("failed to save completion state: %v", err)64+ m = m.setErrorf("Puzzle completed, but saving the final state failed: %v", err)6765 }66+ } else {67+ m = m.setErrorf("Puzzle completed, but reading the final state failed: %v", err)6868 }6969 if err := m.store.UpdateStatus(m.session.activeGameID, store.StatusCompleted); err != nil {70- log.Printf("failed to mark game completed: %v", err)70+ m = m.setErrorf("Puzzle completed, but updating completion status failed: %v", err)7171 }7272 return m7373 }@@ -15,11 +15,13 @@ func (m model) handleMainMenuEnter() (tea.Model, tea.Cmd) {
1515 case "Play":1616 m.nav.playMenu = ui.NewMainMenu(buildPlayMenuItems(time.Now(), m.currentWeeklyMenuIndex()))1717 m.state = playMenuView18+ m = m.clearNotice()1819 case "Stats":1920 return m.handleStatsEnter()2021 case "Options":2122 m.nav.optionsMenu = ui.NewMainMenu(optionsMenuItems)2223 m.state = optionsMenuView24+ m = m.clearNotice()2325 case "Quit":2426 return m, tea.Quit2527 }@@ -51,9 +53,11 @@ func (m model) handlePlayMenuEnter() (tea.Model, tea.Cmd) {
5153 case "Create":5254 m.state = gameSelectView5355 m = m.updateCategoryDetailViewport()56+ m = m.clearNotice()5457 case "Continue":5558 m.nav.continueTable, m.nav.continueGames = ui.InitContinueTable(m.store, m.height)5659 m.state = continueView60+ m = m.clearNotice()5761 case "Daily":5862 return m.handleDailyPuzzle()5963 case "Weekly":@@ -74,6 +78,7 @@ func (m model) handleOptionsMenuEnter() (tea.Model, tea.Cmd) {
7478 listWidth, listHeight := helpSelectListSize(m.width, m.height, m.nav.helpSelectList)7579 m.nav.helpSelectList.SetSize(listWidth, listHeight)7680 m.state = helpSelectView81+ m = m.clearNotice()7782 }7883 return m, nil7984 }@@ -1,8 +1,6 @@
11 package app22 33 import (4- "log"5-64 "github.com/FelineStateMachine/puzzletea/game"75 "github.com/FelineStateMachine/puzzletea/registry"86 "github.com/FelineStateMachine/puzzletea/resolve"@@ -25,15 +23,14 @@ func (m model) handleSeedConfirm() (tea.Model, tea.Cmd) {
2523 2624 rec, err := m.store.GetDailyGame(name)2725 if err != nil {28- log.Printf("failed to check seeded game: %v", err)29- return m, nil26+ return m.setErrorf("Could not check saved seeded puzzle: %v", err), nil3027 }3128 if rec != nil {3229 var resumed bool3330 m, resumed = m.importAndActivateRecord(*rec)3431 if resumed {3532 if err := sessionflow.ResumeAbandonedDeterministicRecord(m.store, rec); err != nil {36- log.Printf("%v", err)33+ m = m.setErrorf("%v", err)3734 }3835 }3936 return m, nil@@ -45,14 +42,12 @@ func (m model) handleSeedConfirm() (tea.Model, tea.Cmd) {
4542 if selectedMode.key == "" {4643 spawner, gameType, modeTitle, err = resolve.SeededMode(seed, registry.Entries())4744 if err != nil {48- log.Printf("failed to select seeded mode: %v", err)49- return m, nil45+ return m.setErrorf("Could not choose a seeded mode: %v", err), nil5046 }5147 } else {5248 spawner, gameType, modeTitle, err = resolve.SeededModeForGame(seed, selectedMode.gameType, registry.Entries())5349 if err != nil {54- log.Printf("failed to select seeded mode for %s: %v", selectedMode.gameType, err)55- return m, nil50+ return m.setErrorf("Could not choose a seeded mode for %s: %v", selectedMode.gameType, err), nil5651 }5752 }5853 @@ -67,5 +62,6 @@ func (m model) handleSeedConfirm() (tea.Model, tea.Cmd) {
6762 exitState: mainMenuView,6863 }6964 m.state = generatingView65+ m = m.clearNotice()7066 return m, tea.Batch(m.spinner.Tick, spawnSeededCmd(spawner, rng, ctx, jobID))7167 }@@ -1,7 +1,6 @@
11 package app22 33 import (4- "log"54 "time"65 76 "github.com/FelineStateMachine/puzzletea/daily"@@ -14,37 +13,34 @@ import (
1413 )1514 1615 func (m model) handleStatsEnter() (tea.Model, tea.Cmd) {16+ m = m.clearNotice()17+1718 catStats, err := m.store.GetCategoryStats()1819 if err != nil {19- log.Printf("failed to get category stats: %v", err)20- return m, nil20+ return m.setErrorf("Could not load category stats: %v", err), nil2121 }2222 modeStats, err := m.store.GetModeStats()2323 if err != nil {24- log.Printf("failed to get mode stats: %v", err)25- return m, nil24+ return m.setErrorf("Could not load mode stats: %v", err), nil2625 }2726 streakDates, err := m.store.GetDailyStreakDates()2827 if err != nil {29- log.Printf("failed to get daily streak dates: %v", err)30- return m, nil28+ return m.setErrorf("Could not load streak data: %v", err), nil3129 }3230 weekliesCompleted, err := m.store.GetCompletedWeeklyGauntlets()3331 if err != nil {34- log.Printf("failed to get weekly gauntlet completions: %v", err)35- return m, nil32+ return m.setErrorf("Could not load weekly progress: %v", err), nil3633 }3734 now := time.Now()3835 currentYear, currentWeek := now.ISOWeek()3936 thisWeekHighestIndex, err := m.store.GetCurrentWeeklyHighestCompletedIndex(currentYear, currentWeek)4037 if err != nil {41- log.Printf("failed to get current weekly progress: %v", err)42- return m, nil38+ return m.setErrorf("Could not load this week’s progress: %v", err), nil4339 }4440 currentDaily := false4541 rec, err := m.store.GetDailyGame(daily.Name(now))4642 if err != nil {47- log.Printf("failed to check current daily: %v", err)43+ m = m.setErrorf("Could not check today’s daily puzzle: %v", err)4844 } else {4945 currentDaily = rec != nil5046 }@@ -1,8 +1,6 @@
11 package app22 33 import (4- "log"5-64 "github.com/FelineStateMachine/puzzletea/config"75 "github.com/FelineStateMachine/puzzletea/theme"86 "github.com/FelineStateMachine/puzzletea/ui"@@ -42,6 +40,7 @@ func (m model) handleThemeEnter() (tea.Model, tea.Cmd) {
4240 }4341 4442 m.state = themeSelectView43+ m = m.clearNotice()4544 return m, nil4645 }4746 @@ -59,7 +58,7 @@ func (m model) handleThemeConfirm() (tea.Model, tea.Cmd) {
5958 _ = theme.Apply(item.ItemTitle)6059 m.cfg.Theme = themeName6160 if err := m.cfg.Save(config.DefaultPath()); err != nil {62- log.Printf("failed to save config: %v", err)61+ m = m.setErrorf("Theme changed, but saving config failed: %v", err)6362 }6463 6564 m.state = mainMenuView@@ -163,6 +163,7 @@ type model struct {
163163 stats statsState164164 theme themeState165165 debug debugState166+ notice noticeState166167 167168 spinner spinner.Model168169 @@ -0,0 +1,54 @@
1+package app2+3+import (4+ "fmt"5+ "log"6+7+ "github.com/FelineStateMachine/puzzletea/ui"8+9+ "charm.land/lipgloss/v2"10+)11+12+type noticeLevel string13+14+const noticeLevelError noticeLevel = "error"15+16+type noticeState struct {17+ level noticeLevel18+ message string19+}20+21+func (m model) clearNotice() model {22+ m.notice = noticeState{}23+ return m24+}25+26+func (m model) setErrorf(format string, args ...any) model {27+ message := fmt.Sprintf(format, args...)28+ log.Printf("%s", message)29+ m.notice = noticeState{30+ level: noticeLevelError,31+ message: message,32+ }33+ return m34+}35+36+func (m model) appendNotice(content string) string {37+ if m.notice.message == "" {38+ return content39+ }40+41+ style := ui.ErrorNoticeStyle()42+ block := style.43+ MaxWidth(max(m.width-8, 24)).44+ Render(m.notice.message)45+ if content == "" {46+ return block47+ }48+ return lipgloss.JoinVertical(lipgloss.Left, content, "", block)49+}50+51+func (m model) renderPanel(title, content, footer string) string {52+ panel := ui.Panel(title, m.appendNotice(content), footer)53+ return ui.CenterView(m.width, m.height, panel)54+}@@ -2,7 +2,6 @@ package app
22 33 import (44 "context"5- "log"65 "math/rand/v2"76 "time"87 @@ -102,7 +101,7 @@ func (m model) handleSpawnComplete(jobID int64, msg game.SpawnCompleteMsg) (tea.
102101 }103102 104103 if msg.Err != nil {105- log.Printf("failed to spawn game: %v", msg.Err)104+ m = m.setErrorf("Could not generate puzzle: %v", msg.Err)106105 if request != nil {107106 m.state = request.returnState108107 }@@ -110,13 +109,12 @@ func (m model) handleSpawnComplete(jobID int64, msg game.SpawnCompleteMsg) (tea.
110109 }111110 112111 if request == nil {113- log.Printf("missing spawn request metadata")114- return m, nil112+ return m.setErrorf("Internal error: missing spawn request metadata"), nil115113 }116114 m = m.activateGame(msg.Game.SetTitle(request.name), 0, false, gameOpenOptions{117115 returnState: request.exitState,118116 weeklyInfo: request.weeklyInfo,119- })117+ }).clearNotice()120118 121119 // Capture initial state and create DB record.122120 rec, err := sessionflow.CreateRecord(@@ -127,7 +125,7 @@ func (m model) handleSpawnComplete(jobID int64, msg game.SpawnCompleteMsg) (tea.
127125 request.modeTitle,128126 )129127 if err != nil {130- log.Printf("failed to create game record: %v", err)128+ m = m.setErrorf("Started puzzle, but could not create a save record: %v", err)131129 } else {132130 m.session.activeGameID = rec.ID133131 }@@ -144,16 +142,15 @@ func saveCurrentGame(m model, status store.GameStatus) model {
144142 }145143 saveData, err := m.session.game.GetSave()146144 if err != nil {147- log.Printf("failed to get save data: %v", err)148- return m145+ return m.setErrorf("Could not save puzzle progress: %v", err)149146 }150147 if err := m.store.UpdateSaveState(m.session.activeGameID, string(saveData)); err != nil {151- log.Printf("failed to update save state: %v", err)148+ m = m.setErrorf("Could not save puzzle progress: %v", err)152149 }153150 // Don't overwrite a completed status when navigating away.154151 if !(m.session.completionSaved && status != store.StatusCompleted) {155152 if err := m.store.UpdateStatus(m.session.activeGameID, status); err != nil {156- log.Printf("failed to update game status: %v", err)153+ m = m.setErrorf("Could not update puzzle status: %v", err)157154 }158155 }159156 return clearActiveGame(m)@@ -64,3 +64,30 @@ func TestGeneratingEscapeCancelsActiveSpawn(t *testing.T) {
6464 t.Fatal("expected spawnCancel to be cleared")6565 }6666 }67+68+func TestHandleSpawnCompleteSurfacingErrors(t *testing.T) {69+ m := model{70+ state: generatingView,71+ session: sessionState{72+ generating: true,73+ spawnJobID: 9,74+ spawn: &spawnRequest{75+ returnState: playMenuView,76+ },77+ },78+ }79+80+ next, _ := m.handleSpawnComplete(9, game.SpawnCompleteMsg{Err: assertiveError("boom")})81+ got := next.(model)82+83+ if got.state != playMenuView {84+ t.Fatalf("state = %d, want %d", got.state, playMenuView)85+ }86+ if got.notice.message == "" {87+ t.Fatal("expected spawn error notice to be populated")88+ }89+}90+91+type assertiveError string92+93+func (e assertiveError) Error() string { return string(e) }@@ -11,53 +11,48 @@ import (
1111 func (m model) viewContent() string {1212 switch m.state {1313 case mainMenuView:14- return ui.CenterView(m.width, m.height, m.nav.mainMenu.View())14+ return ui.CenterView(m.width, m.height, m.appendNotice(m.nav.mainMenu.View()))1515 case playMenuView:16- return ui.CenterView(m.width, m.height, m.nav.playMenu.ViewAsPanel("Play"))16+ return ui.CenterView(m.width, m.height, m.appendNotice(m.nav.playMenu.ViewAsPanel("Play")))1717 case optionsMenuView:1818 items := m.nav.optionsMenu.RenderItems() + "\n\n" + ui.DimItemStyle().Render("- Dami")19- panel := ui.Panel("Options", items, "↑/↓ navigate • enter select • esc back")20- return ui.CenterView(m.width, m.height, panel)19+ return m.renderPanel("Options", items, "↑/↓ navigate • enter select • esc back")2120 case seedInputView:22- panel := ui.Panel(21+ return m.renderPanel(2322 "Enter Seed",2423 m.seedInputBody(),2524 "↑/↓ change field • ←/→ game • enter confirm • esc back",2625 )27- return ui.CenterView(m.width, m.height, panel)2826 case gameSelectView:2927 return m.gameSelectViewContent()3028 case modeSelectView:31- panel := ui.Panel(29+ return m.renderPanel(3230 m.nav.selectedCategory.Definition.Name+" — Select Mode",3331 m.nav.modeSelectList.View(),3432 "↑/↓ navigate • enter select • esc back",3533 )36- return ui.CenterView(m.width, m.height, panel)3734 case generatingView:3835 s := m.spinner.View() + " Generating puzzle..."39- box := ui.GeneratingFrame().Render(s)36+ box := ui.GeneratingFrame().Render(m.appendNotice(s))4037 return ui.CenterView(m.width, m.height, box)4138 case continueView:4239 return m.renderContinueView()4340 case weeklyView:44- return ui.CenterView(m.width, m.height, m.weeklyViewContent())41+ return ui.CenterView(m.width, m.height, m.appendNotice(m.weeklyViewContent()))4542 case gameView:4643 return m.renderGameView()4744 case helpSelectView:48- panel := ui.Panel(45+ return m.renderPanel(4946 "How to Play",5047 m.nav.helpSelectList.View(),5148 "↑/↓ navigate • enter select • esc back",5249 )53- return ui.CenterView(m.width, m.height, panel)5450 case helpDetailView:55- panel := ui.Panel(51+ return m.renderPanel(5652 m.help.category.Definition.Name+" — Guide",5753 m.help.viewport.View(),5854 "↑/↓ scroll • esc back",5955 )60- return ui.CenterView(m.width, m.height, panel)6156 case themeSelectView:6257 return m.themeSelectViewContent()6358 case statsView:@@ -69,16 +64,14 @@ func (m model) viewContent() string {
6964 7065 func (m model) renderContinueView() string {7166 if len(m.nav.continueGames) == 0 {72- panel := ui.Panel("Saved Games", "No saved games yet.", "esc back")73- return ui.CenterView(m.width, m.height, panel)67+ return m.renderPanel("Saved Games", "No saved games yet.", "esc back")7468 }7569 7670 footer := "↑/↓ navigate • enter resume • esc back"7771 if pg := ui.TablePagination(m.nav.continueTable); pg != "" {7872 footer = pg + " " + footer7973 }80- panel := ui.Panel("Saved Games", m.nav.continueTable.View(), footer)81- return ui.CenterView(m.width, m.height, panel)74+ return m.renderPanel("Saved Games", m.nav.continueTable.View(), footer)8275 }8376 8477 func (m model) renderGameView() string {@@ -94,7 +87,7 @@ func (m model) renderGameView() string {
9487 )9588 centered = lipgloss.JoinVertical(lipgloss.Center, gameView, debugInfo)9689 }97- return ui.CenterView(m.width, m.height, centered)90+ return ui.CenterView(m.width, m.height, m.appendNotice(centered))9891 }9992 10093 func (m model) renderStatsView() string {@@ -111,6 +104,5 @@ func (m model) renderStatsView() string {
111104 )112105 }113106 statsBody = lipgloss.NewStyle().Width(statsWidth).Render(statsBody)114- panel := ui.Panel("Stats", statsBody, "↑/↓ scroll • esc back")115- return ui.CenterView(m.width, m.height, panel)107+ return m.renderPanel("Stats", statsBody, "↑/↓ scroll • esc back")116108 }@@ -128,9 +128,16 @@ func TestGameViewEscapeReturnsToMainMenu(t *testing.T) {
128128 129129 func TestImportAndActivateRecordSuccessFlag(t *testing.T) {130130 unknown := model{state: playMenuView}131- if _, ok := unknown.importAndActivateRecord(store.GameRecord{GameType: "NoSuchGameType"}); ok {131+ nextUnknown, ok := unknown.importAndActivateRecord(store.GameRecord{132+ Name: "broken-save",133+ GameType: "NoSuchGameType",134+ })135+ if ok {132136 t.Fatal("expected unknown game type import to fail")133137 }138+ if nextUnknown.notice.message == "" {139+ t.Fatal("expected failed import to surface a user-visible notice")140+ }134141 135142 loadedGame, err := lightsout.New(3, 3)136143 if err != nil {@@ -170,6 +177,9 @@ func TestImportAndActivateRecordSuccessFlag(t *testing.T) {
170177 if next.session.game == nil {171178 t.Fatal("expected game to be activated")172179 }180+ if next.notice.message != "" {181+ t.Fatalf("notice = %q, want empty after successful import", next.notice.message)182+ }173183 }174184 175185 func TestHandleSeedConfirmDoesNotResumeStatusWhenImportFails(t *testing.T) {@@ -49,7 +49,7 @@ func (m model) gameSelectViewContent() string {
4949 5050 panel := ui.Panel(5151 "Select Category",52- body,52+ m.appendNotice(body),5353 "↑/↓ navigate • / filter • enter select • esc back",5454 )5555 return ui.CenterView(m.width, m.height, panel)@@ -202,7 +202,7 @@ func (m model) themeSelectViewContent() string {
202202 203203 panel := ui.Panel(204204 "Select Theme",205- body,205+ m.appendNotice(body),206206 "↑/↓ navigate • / filter • enter select • esc back",207207 )208208 return ui.CenterView(m.width, m.height, panel)@@ -1,9 +1,11 @@
11 package app22 33 import (4+ "strings"45 "testing"56 67 tea "charm.land/bubbletea/v2"8+ "github.com/FelineStateMachine/puzzletea/ui"79 )810 911 func TestGameViewRequestsMouseCellMotion(t *testing.T) {@@ -20,3 +22,19 @@ func TestGameViewRequestsMouseCellMotion(t *testing.T) {
2022 t.Fatal("expected keyboard event type reporting to remain enabled")2123 }2224 }25+26+func TestPlayMenuViewIncludesNotice(t *testing.T) {27+ m := model{28+ state: playMenuView,29+ width: 80,30+ height: 24,31+ nav: navigationState{32+ playMenu: ui.NewMainMenu(nil),33+ },34+ notice: noticeState{level: noticeLevelError, message: "Could not load puzzle"},35+ }36+37+ if got := m.viewContent(); !strings.Contains(got, "Could not load puzzle") {38+ t.Fatalf("viewContent() missing notice, got %q", got)39+ }40+}@@ -1,7 +1,6 @@
11 package app22 33 import (4- "log"54 "sort"65 "strconv"76 "time"@@ -50,8 +49,10 @@ func (m model) refreshWeeklyBrowser() model {
5049 year, weekNumber := m.selectedWeek()5150 games, err := m.store.ListWeeklyGames(year, weekNumber)5251 if err != nil {53- log.Printf("failed to list weekly games: %v", err)52+ m = m.setErrorf("Could not load weekly puzzles: %v", err)5453 games = nil54+ } else {55+ m = m.clearNotice()5556 }5657 5758 if m.isCurrentWeeklySelection() {@@ -141,7 +142,7 @@ func (m model) openWeeklyRow(row weeklyRow) (tea.Model, tea.Cmd) {
141142 m, resumed = m.importAndActivateRecordWithOptions(*row.Record, options)142143 if resumed {143144 if err := sessionflow.ResumeAbandonedDeterministicRecord(m.store, row.Record); err != nil {144- log.Printf("%v", err)145+ m = m.setErrorf("%v", err)145146 }146147 }147148 return m, nil@@ -153,8 +154,7 @@ func (m model) openWeeklyRow(row weeklyRow) (tea.Model, tea.Cmd) {
153154 154155 spawner, gameType, modeTitle := weekly.Mode(info.Year, info.Week, info.Index)155156 if spawner == nil {156- log.Printf("no weekly mode available for %s", row.Name)157- return m, nil157+ return m.setErrorf("No weekly puzzle is configured for %s", row.Name), nil158158 }159159 160160 rng := weekly.RNG(info.Year, info.Week, info.Index)@@ -170,6 +170,7 @@ func (m model) openWeeklyRow(row weeklyRow) (tea.Model, tea.Cmd) {
170170 weeklyInfo: &infoCopy,171171 }172172 m.state = generatingView173+ m = m.clearNotice()173174 return m, tea.Batch(m.spinner.Tick, spawnSeededCmd(spawner, rng, ctx, jobID))174175 }175176 @@ -56,18 +56,12 @@ var Modes = []game.Mode{
5656 NewMode("Expert 12x12", "Wide board with long deduction chains.", 12, 9, 0.52),5757 }5858 59-var DailyModes = []game.Mode{60- Modes[1],61- Modes[2],62- Modes[3],63-}64-65-var Definition = game.Definition{66- Name: "Fillomino",67- Description: "Grow the numbered regions to their exact sizes.",68- Aliases: []string{"polyomino", "regions"},69- Modes: Modes,70- DailyModes: DailyModes,71- Help: HelpContent,72- Import: func(data []byte) (game.Gamer, error) { return ImportModel(data) },73-}59+var Definition = game.NewDefinition(game.DefinitionSpec{60+ Name: "Fillomino",61+ Description: "Grow the numbered regions to their exact sizes.",62+ Aliases: []string{"polyomino", "regions"},63+ Modes: Modes,64+ DailyModeIndexes: []int{1, 2, 3},65+ Help: HelpContent,66+ Import: game.AdaptImport(ImportModel),67+})@@ -239,6 +239,36 @@ func TestDefinitionCategory(t *testing.T) {
239239 }240240 }241241 242+func TestNewDefinitionSelectsDailyModesByIndex(t *testing.T) {243+ modes := []Mode{244+ NewBaseMode("Beginner", "Easy board"),245+ NewBaseMode("Medium", "Balanced board"),246+ NewBaseMode("Expert", "Hard board"),247+ }248+249+ def := NewDefinition(DefinitionSpec{250+ Name: "Sudoku",251+ Description: "Classic grid logic",252+ Aliases: []string{"classic"},253+ Modes: modes,254+ DailyModeIndexes: []int{1, 2, 99, -1},255+ Help: "Rules",256+ })257+258+ if got, want := len(def.DailyModes), 2; got != want {259+ t.Fatalf("len(DailyModes) = %d, want %d", got, want)260+ }261+ if got, want := def.DailyModes[0].Title(), "Medium"; got != want {262+ t.Fatalf("DailyModes[0] = %q, want %q", got, want)263+ }264+ if got, want := def.DailyModes[1].Title(), "Expert"; got != want {265+ t.Fatalf("DailyModes[1] = %q, want %q", got, want)266+ }267+ if got, want := len(def.Aliases), 1; got != want {268+ t.Fatalf("len(Aliases) = %d, want %d", got, want)269+ }270+}271+242272 func TestNormalizeName(t *testing.T) {243273 tests := []struct {244274 input string@@ -99,6 +99,45 @@ type Definition struct {
9999 Import func([]byte) (Gamer, error)100100 }101101 102+type DefinitionSpec struct {103+ Name string104+ Description string105+ Aliases []string106+ Modes []Mode107+ DailyModeIndexes []int108+ Help string109+ Import func([]byte) (Gamer, error)110+}111+112+func NewDefinition(spec DefinitionSpec) Definition {113+ return Definition{114+ Name: spec.Name,115+ Description: spec.Description,116+ Aliases: append([]string(nil), spec.Aliases...),117+ Modes: append([]Mode(nil), spec.Modes...),118+ DailyModes: SelectModesByIndex(spec.Modes, spec.DailyModeIndexes...),119+ Help: spec.Help,120+ Import: spec.Import,121+ }122+}123+124+func SelectModesByIndex(modes []Mode, indexes ...int) []Mode {125+ selected := make([]Mode, 0, len(indexes))126+ for _, idx := range indexes {127+ if idx < 0 || idx >= len(modes) {128+ continue129+ }130+ selected = append(selected, modes[idx])131+ }132+ return selected133+}134+135+func AdaptImport[T Gamer](fn func([]byte) (T, error)) func([]byte) (Gamer, error) {136+ return func(data []byte) (Gamer, error) {137+ return fn(data)138+ }139+}140+102141 func (d Definition) Category() Category {103142 modes := make([]list.Item, len(d.Modes))104143 for i, m := range d.Modes {@@ -65,17 +65,12 @@ var Modes = []game.Mode{
6565 NewMode("Hard 13x13", "13x13 grid with 59-68 islands.", 13, 13, 59, 68),6666 }6767 68-var DailyModes = []game.Mode{69- Modes[3], // Easy 9x970- Modes[1], // Medium 7x771-}72-73-var Definition = game.Definition{74- Name: "Hashiwokakero",75- Description: "Connect the islands with bridges.",76- Aliases: []string{"hashi", "bridges"},77- Modes: Modes,78- DailyModes: DailyModes,79- Help: HelpContent,80- Import: func(data []byte) (game.Gamer, error) { return ImportModel(data) },81-}68+var Definition = game.NewDefinition(game.DefinitionSpec{69+ Name: "Hashiwokakero",70+ Description: "Connect the islands with bridges.",71+ Aliases: []string{"hashi", "bridges"},72+ Modes: Modes,73+ DailyModeIndexes: []int{3, 1},74+ Help: HelpContent,75+ Import: game.AdaptImport(ImportModel),76+})@@ -55,16 +55,11 @@ var Modes = []game.Mode{
5555 NewMode("Expert", "12\u00d712 grid, maximum challenge.", 12, 0.28),5656 }5757 58-var DailyModes = []game.Mode{59- Modes[1], // Easy 6x660- Modes[2], // Medium 8x861-}62-63-var Definition = game.Definition{64- Name: "Hitori",65- Description: "Shade the cells to eliminate duplicates.",66- Modes: Modes,67- DailyModes: DailyModes,68- Help: HelpContent,69- Import: func(data []byte) (game.Gamer, error) { return ImportModel(data) },70-}58+var Definition = game.NewDefinition(game.DefinitionSpec{59+ Name: "Hitori",60+ Description: "Shade the cells to eliminate duplicates.",61+ Modes: Modes,62+ DailyModeIndexes: []int{1, 2},63+ Help: HelpContent,64+ Import: game.AdaptImport(ImportModel),65+})@@ -44,17 +44,12 @@ var Modes = []game.Mode{
4444 NewMode("Extreme", "9x9 grid", 9, 9),4545 }4646 47-var DailyModes = []game.Mode{48- Modes[1], // Medium 5x549- Modes[2], // Hard 7x750-}51-52-var Definition = game.Definition{53- Name: "Lights Out",54- Description: "Turn the lights off.",55- Aliases: []string{"lights"},56- Modes: Modes,57- DailyModes: DailyModes,58- Help: HelpContent,59- Import: func(data []byte) (game.Gamer, error) { return ImportModel(data) },60-}47+var Definition = game.NewDefinition(game.DefinitionSpec{48+ Name: "Lights Out",49+ Description: "Turn the lights off.",50+ Aliases: []string{"lights"},51+ Modes: Modes,52+ DailyModeIndexes: []int{1, 2},53+ Help: HelpContent,54+ Import: game.AdaptImport(ImportModel),55+})@@ -58,16 +58,11 @@ var Modes = []game.Mode{
5858 NewMode("Massive", "20x20 grid, ~56% filled. Truly massive puzzle.", 20, 20, 0.56),5959 }6060 61-var DailyModes = []game.Mode{62- Modes[3], // Standard 10x1063- Modes[4], // Classic 10x1064-}65-66-var Definition = game.Definition{67- Name: "Nonogram",68- Description: "Fill the cells to match tomographic hints.",69- Modes: Modes,70- DailyModes: DailyModes,71- Help: HelpContent,72- Import: func(data []byte) (game.Gamer, error) { return ImportModel(data) },73-}61+var Definition = game.NewDefinition(game.DefinitionSpec{62+ Name: "Nonogram",63+ Description: "Fill the cells to match tomographic hints.",64+ Modes: Modes,65+ DailyModeIndexes: []int{3, 4},66+ Help: HelpContent,67+ Import: game.AdaptImport(ImportModel),68+})@@ -73,17 +73,12 @@ var Modes = []game.Mode{
7373 NewMode("Expert", "12x12 grid, sparse clues and long chains.", 12, 12, 0.14, 12),7474 }7575 76-var DailyModes = []game.Mode{77- Modes[1], // Easy78- Modes[2], // Medium79-}80-81-var Definition = game.Definition{82- Name: "Nurikabe",83- Description: "Split the land while keeping one connected sea.",84- Aliases: []string{"islands", "sea"},85- Modes: Modes,86- DailyModes: DailyModes,87- Help: HelpContent,88- Import: func(data []byte) (game.Gamer, error) { return ImportModel(data) },89-}76+var Definition = game.NewDefinition(game.DefinitionSpec{77+ Name: "Nurikabe",78+ Description: "Split the land while keeping one connected sea.",79+ Aliases: []string{"islands", "sea"},80+ Modes: Modes,81+ DailyModeIndexes: []int{1, 2},82+ Help: HelpContent,83+ Import: game.AdaptImport(ImportModel),84+})@@ -173,20 +173,12 @@ func parsePuzzleSection(section []string, path string, startLine int, meta PackM
173173 Body: body,174174 }175175 176- if strings.EqualFold(strings.TrimSpace(meta.Category), "nonogram") {177- nonogram, err := parseNonogramBody(bodyLines, path, startLine+1)178- if err != nil {179- return Puzzle{}, err180- }181- p.PrintPayload = nonogram182- return p, nil183- }184-185- table, err := parseGridTableBody(bodyLines, path, startLine+1)176+ parser := lookupMarkdownBodyParser(meta.Category)177+ payload, err := parser.ParseMarkdownBody(bodyLines, path, startLine+1)186178 if err != nil {187179 return Puzzle{}, err188180 }189- p.PrintPayload = table181+ p.PrintPayload = payload190182 191183 return p, nil192184 }@@ -0,0 +1,31 @@
1+package pdfexport2+3+import "strings"4+5+type markdownBodyParser interface {6+ ParseMarkdownBody(bodyLines []string, path string, bodyStartLine int) (any, error)7+}8+9+type markdownBodyParserFunc func(bodyLines []string, path string, bodyStartLine int) (any, error)10+11+func (f markdownBodyParserFunc) ParseMarkdownBody(bodyLines []string, path string, bodyStartLine int) (any, error) {12+ return f(bodyLines, path, bodyStartLine)13+}14+15+var markdownBodyParsers = map[string]markdownBodyParser{16+ normalizeGameTypeToken("nonogram"): markdownBodyParserFunc(func(bodyLines []string, path string, bodyStartLine int) (any, error) {17+ return parseNonogramBody(bodyLines, path, bodyStartLine)18+ }),19+}20+21+var defaultMarkdownBodyParser markdownBodyParser = markdownBodyParserFunc(func(bodyLines []string, path string, bodyStartLine int) (any, error) {22+ return parseGridTableBody(bodyLines, path, bodyStartLine)23+})24+25+func lookupMarkdownBodyParser(category string) markdownBodyParser {26+ normalized := normalizeGameTypeToken(strings.TrimSpace(category))27+ if parser, ok := markdownBodyParsers[normalized]; ok {28+ return parser29+ }30+ return defaultMarkdownBodyParser31+}@@ -43,22 +43,22 @@ type DailyEntry struct {
4343 Mode string4444 }4545 46-var all = []Entry{47- adaptLegacy(fillomino.Definition),48- adaptLegacy(hashiwokakero.Definition),49- adaptLegacy(hitori.Definition),50- adaptLegacy(lightsout.Definition),51- adaptLegacy(nonogram.Definition),52- adaptLegacy(nurikabe.Definition),53- adaptLegacy(rippleeffect.Definition),54- adaptLegacy(shikaku.Definition),55- adaptLegacy(spellpuzzle.Definition),56- adaptLegacy(sudoku.Definition),57- adaptLegacy(sudokurgb.Definition),58- adaptLegacy(takuzu.Definition),59- adaptLegacy(takuzuplus.Definition),60- adaptLegacy(wordsearch.Definition),61-}46+var all = buildEntries(47+ fillomino.Definition,48+ hashiwokakero.Definition,49+ hitori.Definition,50+ lightsout.Definition,51+ nonogram.Definition,52+ nurikabe.Definition,53+ rippleeffect.Definition,54+ shikaku.Definition,55+ spellpuzzle.Definition,56+ sudoku.Definition,57+ sudokurgb.Definition,58+ takuzu.Definition,59+ takuzuplus.Definition,60+ wordsearch.Definition,61+)6262 6363 var (6464 definitions = buildDefinitions(all)@@ -112,6 +112,14 @@ func adaptLegacy(def game.Definition) Entry {
112112 }113113 }114114 115+func buildEntries(defs ...game.Definition) []Entry {116+ entries := make([]Entry, 0, len(defs))117+ for _, def := range defs {118+ entries = append(entries, adaptLegacy(def))119+ }120+ return entries121+}122+115123 func extractModeDefs(modes []ModeEntry) []puzzle.ModeDef {116124 defs := make([]puzzle.ModeDef, 0, len(modes))117125 for _, mode := range modes {@@ -33,3 +33,24 @@ func TestModeSeededFlagMatchesSpawnerAvailability(t *testing.T) {
3333 }3434 }3535 }36+37+func TestEntriesStayAlignedWithDefinitions(t *testing.T) {38+ definitions := Definitions()39+ entries := Entries()40+ if got, want := len(entries), len(definitions); got != want {41+ t.Fatalf("len(Entries()) = %d, want %d", got, want)42+ }43+44+ for _, def := range definitions {45+ entry, ok := Lookup(def.Name)46+ if !ok {47+ t.Fatalf("Lookup(%q) = false", def.Name)48+ }49+ if entry.Import == nil {50+ t.Fatalf("%s missing import function", def.Name)51+ }52+ if got, want := len(entry.Modes), len(def.Modes); got != want {53+ t.Fatalf("%s mode count = %d, want %d", def.Name, got, want)54+ }55+ }56+}@@ -122,21 +122,15 @@ var Modes = []game.Mode{
122122 ),123123 }124124 125-var DailyModes = []game.Mode{126- Modes[1],127- Modes[2],128- Modes[3],129-}130-131-var Definition = game.Definition{132- Name: "Ripple Effect",133- Description: "Fill the cages with sequential numbers without violating ripple distance.",134- Aliases: []string{"ripple"},135- Modes: Modes,136- DailyModes: DailyModes,137- Help: HelpContent,138- Import: func(data []byte) (game.Gamer, error) { return ImportModel(data) },139-}125+var Definition = game.NewDefinition(game.DefinitionSpec{126+ Name: "Ripple Effect",127+ Description: "Fill the cages with sequential numbers without violating ripple distance.",128+ Aliases: []string{"ripple"},129+ Modes: Modes,130+ DailyModeIndexes: []int{1, 2, 3},131+ Help: HelpContent,132+ Import: game.AdaptImport(ImportModel),133+})140134 141135 func (m Mode) generatePuzzle() (Puzzle, error) {142136 rng := rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))@@ -56,17 +56,12 @@ var Modes = []game.Mode{
5656 NewMode("Expert 12x12", "12x12 grid, maximum challenge.", 12, 12, 20),5757 }5858 59-var DailyModes = []game.Mode{60- Modes[1], // Easy 7x761- Modes[2], // Medium 8x862-}63-64-var Definition = game.Definition{65- Name: "Shikaku",66- Description: "Divide the grid into rectangles with set sizes.",67- Aliases: []string{"rectangles"},68- Modes: Modes,69- DailyModes: DailyModes,70- Help: HelpContent,71- Import: func(data []byte) (game.Gamer, error) { return ImportModel(data) },72-}59+var Definition = game.NewDefinition(game.DefinitionSpec{60+ Name: "Shikaku",61+ Description: "Divide the grid into rectangles with set sizes.",62+ Aliases: []string{"rectangles"},63+ Modes: Modes,64+ DailyModeIndexes: []int{1, 2},65+ Help: HelpContent,66+ Import: game.AdaptImport(ImportModel),67+})@@ -55,16 +55,12 @@ var Modes = []game.Mode{
5555 NewMode("Hard", "9 letters, 9 board words, largest launch board.", 9, 9, 8),5656 }5757 58-var DailyModes = []game.Mode{59- Modes[0],60-}61-62-var Definition = game.Definition{63- Name: "Spell Puzzle",64- Description: "Connect letters to fill a crossword with bonus anagrams.",65- Aliases: []string{"spell", "spellpuzzle"},66- Modes: Modes,67- DailyModes: DailyModes,68- Help: HelpContent,69- Import: func(data []byte) (game.Gamer, error) { return ImportModel(data) },70-}58+var Definition = game.NewDefinition(game.DefinitionSpec{59+ Name: "Spell Puzzle",60+ Description: "Connect letters to fill a crossword with bonus anagrams.",61+ Aliases: []string{"spell", "spellpuzzle"},62+ Modes: Modes,63+ DailyModeIndexes: []int{0},64+ Help: HelpContent,65+ Import: game.AdaptImport(ImportModel),66+})@@ -5,6 +5,20 @@ import (
55 "fmt"66 )77 8+const currentSchemaVersion = 39+10+type migration struct {11+ version int12+ name string13+ apply func(*sql.DB) error14+}15+16+var schemaMigrations = []migration{17+ {version: 1, name: "create games table", apply: createGamesSchema},18+ {version: 2, name: "add game metadata columns", apply: migrateGameMetadata},19+ {version: 3, name: "refresh stats views", apply: refreshStatsViews},20+}21+822 type gameRowMeta struct {923 ID int641024 Name string@@ -15,6 +29,145 @@ type gameRowMeta struct {
1529 RunKind string1630 }1731 32+func runMigrations(db *sql.DB) error {33+ if err := ensureSchemaMigrationsTable(db); err != nil {34+ return err35+ }36+37+ version, err := schemaVersion(db)38+ if err != nil {39+ return err40+ }41+ if version == 0 {42+ version, err = detectSchemaVersion(db)43+ if err != nil {44+ return err45+ }46+ if err := setSchemaVersion(db, version); err != nil {47+ return err48+ }49+ }50+51+ for _, m := range schemaMigrations {52+ if m.version <= version {53+ continue54+ }55+ if err := m.apply(db); err != nil {56+ return fmt.Errorf("applying migration %d (%s): %w", m.version, m.name, err)57+ }58+ if err := setSchemaVersion(db, m.version); err != nil {59+ return err60+ }61+ }62+63+ return nil64+}65+66+func ensureSchemaMigrationsTable(db *sql.DB) error {67+ _, err := db.Exec(`68+CREATE TABLE IF NOT EXISTS schema_migrations (69+ id INTEGER PRIMARY KEY CHECK(id = 1),70+ version INTEGER NOT NULL,71+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP72+);`)73+ if err != nil {74+ return fmt.Errorf("creating schema_migrations table: %w", err)75+ }76+ return nil77+}78+79+func schemaVersion(db *sql.DB) (int, error) {80+ var version int81+ err := db.QueryRow(`SELECT version FROM schema_migrations WHERE id = 1`).Scan(&version)82+ if err == sql.ErrNoRows {83+ return 0, nil84+ }85+ if err != nil {86+ return 0, fmt.Errorf("reading schema version: %w", err)87+ }88+ return version, nil89+}90+91+func setSchemaVersion(db *sql.DB, version int) error {92+ _, err := db.Exec(`93+INSERT INTO schema_migrations (id, version)94+VALUES (1, ?)95+ON CONFLICT(id) DO UPDATE96+SET version = excluded.version,97+ updated_at = CURRENT_TIMESTAMP98+`, version)99+ if err != nil {100+ return fmt.Errorf("writing schema version %d: %w", version, err)101+ }102+ return nil103+}104+105+func detectSchemaVersion(db *sql.DB) (int, error) {106+ exists, err := tableExists(db, "games")107+ if err != nil {108+ return 0, err109+ }110+ if !exists {111+ return 0, nil112+ }113+114+ columns, err := tableColumns(db, "games")115+ if err != nil {116+ return 0, err117+ }118+119+ required := []string{120+ "game_id",121+ "mode_id",122+ "run_kind",123+ "run_date",124+ "week_year",125+ "week_number",126+ "week_index",127+ "seed_text",128+ }129+ for _, column := range required {130+ if !columns[column] {131+ return 1, nil132+ }133+ }134+135+ return 2, nil136+}137+138+func createGamesSchema(db *sql.DB) error {139+ if _, err := db.Exec(createTableSQL); err != nil {140+ return fmt.Errorf("creating games table: %w", err)141+ }142+ return nil143+}144+145+func migrateGameMetadata(db *sql.DB) error {146+ if err := ensureGameColumns(db); err != nil {147+ return err148+ }149+ if err := backfillGameMetadata(db); err != nil {150+ return err151+ }152+ return nil153+}154+155+func refreshStatsViews(db *sql.DB) error {156+ if _, err := db.Exec(`DROP VIEW IF EXISTS category_stats`); err != nil {157+ return fmt.Errorf("dropping category_stats view: %w", err)158+ }159+ if _, err := db.Exec(`DROP VIEW IF EXISTS mode_stats`); err != nil {160+ return fmt.Errorf("dropping mode_stats view: %w", err)161+ }162+ if _, err := db.Exec(createCategoryStatsViewSQL); err != nil {163+ return fmt.Errorf("creating category_stats view: %w", err)164+ }165+ if _, err := db.Exec(createModeStatsViewSQL); err != nil {166+ return fmt.Errorf("creating mode_stats view: %w", err)167+ }168+ return nil169+}170+18171 func ensureGameColumns(db *sql.DB) error {19172 columns := []struct {20173 name string@@ -70,6 +223,18 @@ func tableColumns(db *sql.DB, table string) (map[string]bool, error) {
70223 return columns, rows.Err()71224 }72225 226+func tableExists(db *sql.DB, table string) (bool, error) {227+ var name string228+ err := db.QueryRow(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`, table).Scan(&name)229+ if err == sql.ErrNoRows {230+ return false, nil231+ }232+ if err != nil {233+ return false, fmt.Errorf("checking %s existence: %w", table, err)234+ }235+ return true, nil236+}237+73238 func backfillGameMetadata(db *sql.DB) error {74239 rows, err := db.Query(`SELECT id, name, game_type, mode, game_id, mode_id, run_kind FROM games`)75240 if err != nil {@@ -163,38 +163,10 @@ func Open(dbPath string) (*Store, error) {
163163 return nil, fmt.Errorf("enabling WAL mode: %w", err)164164 }165165 166- if _, err := db.Exec(createTableSQL); err != nil {167- db.Close()168- return nil, fmt.Errorf("creating table: %w", err)169- }170-171- if err := ensureGameColumns(db); err != nil {166+ if err := runMigrations(db); err != nil {172167 db.Close()173168 return nil, err174169 }175- if err := backfillGameMetadata(db); err != nil {176- db.Close()177- return nil, err178- }179-180- if _, err := db.Exec(`DROP VIEW IF EXISTS category_stats`); err != nil {181- db.Close()182- return nil, fmt.Errorf("dropping category_stats view: %w", err)183- }184- if _, err := db.Exec(`DROP VIEW IF EXISTS mode_stats`); err != nil {185- db.Close()186- return nil, fmt.Errorf("dropping mode_stats view: %w", err)187- }188-189- if _, err := db.Exec(createCategoryStatsViewSQL); err != nil {190- db.Close()191- return nil, fmt.Errorf("creating category_stats view: %w", err)192- }193-194- if _, err := db.Exec(createModeStatsViewSQL); err != nil {195- db.Close()196- return nil, fmt.Errorf("creating mode_stats view: %w", err)197- }198170 199171 return &Store{db: db}, nil200172 }@@ -1,6 +1,7 @@
11 package store22 33 import (4+ "database/sql"45 "path/filepath"56 "testing"67 "time"@@ -68,6 +69,101 @@ func TestOpen(t *testing.T) {
6869 t.Fatal("expected record to persist across reopen")6970 }7071 })72+73+ t.Run("records current schema version", func(t *testing.T) {74+ s := openTestStore(t)75+76+ version, err := schemaVersion(s.db)77+ if err != nil {78+ t.Fatal(err)79+ }80+ if got, want := version, currentSchemaVersion; got != want {81+ t.Fatalf("schema version = %d, want %d", got, want)82+ }83+ })84+85+ t.Run("upgrades legacy databases and backfills metadata", func(t *testing.T) {86+ dbPath := filepath.Join(t.TempDir(), "legacy.db")87+88+ raw, err := sql.Open("sqlite", dbPath)89+ if err != nil {90+ t.Fatal(err)91+ }92+ t.Cleanup(func() { raw.Close() })93+94+ _, err = raw.Exec(`95+CREATE TABLE games (96+ id INTEGER PRIMARY KEY AUTOINCREMENT,97+ name TEXT NOT NULL UNIQUE,98+ game_type TEXT NOT NULL,99+ mode TEXT NOT NULL,100+ initial_state TEXT NOT NULL,101+ save_state TEXT,102+ status TEXT NOT NULL DEFAULT 'new'103+ CHECK(status IN ('new','in_progress','completed','abandoned')),104+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,105+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,106+ completed_at DATETIME107+);`)108+ if err != nil {109+ t.Fatal(err)110+ }111+112+ _, err = raw.Exec(`113+INSERT INTO games (name, game_type, mode, initial_state, save_state, status)114+VALUES (?, ?, ?, ?, ?, ?)`,115+ "Daily Feb 16 26 - amber-fox",116+ "Sudoku",117+ "Easy",118+ `{"grid":"initial"}`,119+ `{"grid":"save"}`,120+ string(StatusNew),121+ )122+ if err != nil {123+ t.Fatal(err)124+ }125+ if err := raw.Close(); err != nil {126+ t.Fatal(err)127+ }128+129+ s, err := Open(dbPath)130+ if err != nil {131+ t.Fatal(err)132+ }133+ defer s.Close()134+135+ version, err := schemaVersion(s.db)136+ if err != nil {137+ t.Fatal(err)138+ }139+ if got, want := version, currentSchemaVersion; got != want {140+ t.Fatalf("schema version = %d, want %d", got, want)141+ }142+143+ rec, err := s.GetGameByName("Daily Feb 16 26 - amber-fox")144+ if err != nil {145+ t.Fatal(err)146+ }147+ if rec == nil {148+ t.Fatal("expected migrated record")149+ }150+ if got, want := rec.GameID, "sudoku"; got != want {151+ t.Fatalf("GameID = %q, want %q", got, want)152+ }153+ if got, want := rec.ModeID, "easy"; got != want {154+ t.Fatalf("ModeID = %q, want %q", got, want)155+ }156+ if got, want := rec.RunKind, RunKindDaily; got != want {157+ t.Fatalf("RunKind = %q, want %q", got, want)158+ }159+ if rec.RunDate == nil {160+ t.Fatal("expected RunDate to be backfilled")161+ }162+163+ if _, err := s.GetCategoryStats(); err != nil {164+ t.Fatalf("GetCategoryStats() error after migration: %v", err)165+ }166+ })71167 }72168 73169 func TestCreateGame(t *testing.T) {@@ -45,16 +45,11 @@ var Modes = []game.Mode{
4545 NewMode("Diabolical", "17–21 clues. Swordfish / XY-Chains.", 17),4646 }4747 48-var DailyModes = []game.Mode{49- Modes[1], // Easy50- Modes[2], // Medium51-}52-53-var Definition = game.Definition{54- Name: "Sudoku",55- Description: "Fill the 9x9 grid following sudoku rules.",56- Modes: Modes,57- DailyModes: DailyModes,58- Help: HelpContent,59- Import: func(data []byte) (game.Gamer, error) { return ImportModel(data) },60-}48+var Definition = game.NewDefinition(game.DefinitionSpec{49+ Name: "Sudoku",50+ Description: "Fill the 9x9 grid following sudoku rules.",51+ Modes: Modes,52+ DailyModeIndexes: []int{1, 2},53+ Help: HelpContent,54+ Import: game.AdaptImport(ImportModel),55+})@@ -45,17 +45,12 @@ var Modes = []game.Mode{
4545 NewMode("Diabolical", "30 clues. Tightest clue budget in the launch set.", 30),4646 }4747 48-var DailyModes = []game.Mode{49- Modes[1], // Easy50- Modes[2], // Medium51-}52-53-var Definition = game.Definition{54- Name: "Sudoku RGB",55- 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.",56- Aliases: []string{"rgb sudoku", "ripeto", "sudoku ripeto"},57- Modes: Modes,58- DailyModes: DailyModes,59- Help: HelpContent,60- Import: func(data []byte) (game.Gamer, error) { return ImportModel(data) },61-}48+var Definition = game.NewDefinition(game.DefinitionSpec{49+ Name: "Sudoku RGB",50+ 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.",51+ Aliases: []string{"rgb sudoku", "ripeto", "sudoku ripeto"},52+ Modes: Modes,53+ DailyModeIndexes: []int{1, 2},54+ Help: HelpContent,55+ Import: game.AdaptImport(ImportModel),56+})@@ -52,17 +52,12 @@ var Modes = []game.Mode{
5252 NewMode("Extreme", "14×14 grid, ~28% clues. Maximum challenge.", 14, 0.28),5353 }5454 55-var DailyModes = []game.Mode{56- Modes[2], // Medium 8x857- Modes[3], // Tricky 10x1058-}59-60-var Definition = game.Definition{61- Name: "Takuzu",62- Description: "Fill the grid with ● and ○. No 3 in a row.",63- Aliases: []string{"binairo", "binary"},64- Modes: Modes,65- DailyModes: DailyModes,66- Help: HelpContent,67- Import: func(data []byte) (game.Gamer, error) { return ImportModel(data) },68-}55+var Definition = game.NewDefinition(game.DefinitionSpec{56+ Name: "Takuzu",57+ Description: "Fill the grid with ● and ○. No 3 in a row.",58+ Aliases: []string{"binairo", "binary"},59+ Modes: Modes,60+ DailyModeIndexes: []int{2, 3},61+ Help: HelpContent,62+ Import: game.AdaptImport(ImportModel),63+})@@ -56,17 +56,12 @@ var Modes = []game.Mode{
5656 NewMode("Extreme", "14×14 grid, maximum size with additive relation clues.", 14, 0.28, modeProfiles[6]),5757 }5858 59-var DailyModes = []game.Mode{60- Modes[2],61- Modes[3],62-}63-64-var Definition = game.Definition{65- Name: "Takuzu+",66- Description: "Fill the grid with ● and ○ using some relational clues. No 3 in a row.",67- Aliases: []string{"takuzu plus", "binario+", "binario plus"},68- Modes: Modes,69- DailyModes: DailyModes,70- Help: HelpContent,71- Import: func(data []byte) (game.Gamer, error) { return ImportModel(data) },72-}59+var Definition = game.NewDefinition(game.DefinitionSpec{60+ Name: "Takuzu+",61+ Description: "Fill the grid with ● and ○ using some relational clues. No 3 in a row.",62+ Aliases: []string{"takuzu plus", "binario+", "binario plus"},63+ Modes: Modes,64+ DailyModeIndexes: []int{2, 3},65+ Help: HelpContent,66+ Import: game.AdaptImport(ImportModel),67+})@@ -15,6 +15,15 @@ func DebugStyle() lipgloss.Style {
1515 BorderForeground(theme.Current().Error)1616 }1717 18+// ErrorNoticeStyle returns the style for user-visible error notices.19+func ErrorNoticeStyle() lipgloss.Style {20+ return lipgloss.NewStyle().21+ Border(lipgloss.RoundedBorder()).22+ BorderForeground(theme.Current().Error).23+ Foreground(theme.Current().Error).24+ Padding(0, 1)25+}26+1827 // LogoStyle returns the style for the ASCII art brand logo.1928 func LogoStyle() lipgloss.Style {2029 p := theme.Current()@@ -54,16 +54,12 @@ var Modes = []game.Mode{
5454 NewMode("Hard 20x20", "Find 15 words in a 20x20 grid.", 20, 20, 15, 5, 10, []Direction{Right, Down, DownRight, DownLeft, Left, Up, UpRight, UpLeft}),5555 }5656 57-var DailyModes = []game.Mode{58- Modes[0], // Easy 10x1059-}60-61-var Definition = game.Definition{62- Name: "Word Search",63- Description: "Find the hidden words in a letter grid.",64- Aliases: []string{"words", "wordsearch", "ws"},65- Modes: Modes,66- DailyModes: DailyModes,67- Help: HelpContent,68- Import: func(data []byte) (game.Gamer, error) { return ImportModel(data) },69-}57+var Definition = game.NewDefinition(game.DefinitionSpec{58+ Name: "Word Search",59+ Description: "Find the hidden words in a letter grid.",60+ Aliases: []string{"words", "wordsearch", "ws"},61+ Modes: Modes,62+ DailyModeIndexes: []int{0},63+ Help: HelpContent,64+ Import: game.AdaptImport(ImportModel),65+})