clone

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

tech debt 3

Dami committed 504785e
commit
504785ea074afcc37e053973bbdecb9eb5471480
parent
b6747ff1e16f74a802ae6543d2598e4a8d88c814
author
Dami <dami.etoile@gmail.com>
40 files changed, +711 -327
Mapp/game_session.go7+-
Mapp/handle_daily.go10+-
Mapp/handle_game.go8+-
Mapp/handle_menu.go5+
Mapp/handle_seed.go14+--
Mapp/handle_stats.go20+--
Mapp/handle_theme.go5+-
Mapp/model.go1+
Aapp/notice.go54++++++++++
Mapp/spawn.go17+--
Mapp/spawn_test.go27+++++
Mapp/state_view.go34++----
Mapp/update_test.go12++-
Mapp/view.go4+-
Mapp/view_test.go18+++
Mapp/weekly.go11+-
Mfillomino/gamemode.go24++---
Mgame/game_test.go30+++++
Mgame/gamer.go39+++++++
Mhashiwokakero/gamemode.go23++---
Mhitori/gamemode.go21+--
Mlightsout/gamemode.go23++---
Mnonogram/gamemode.go21+--
Mnurikabe/gamemode.go23++---
Mpdfexport/parse.go14+--
Apdfexport/parse_handlers.go31++++++
Mregistry/registry.go40++++---
Mregistry/registry_test.go21++++
Mrippleeffect/gamemode.go24++---
Mshikaku/gamemode.go23++---
Mspellpuzzle/gamemode.go22++--
Mstore/migrate.go165++++++++++++++++++++++++++++++
Mstore/store.go30+-----
Mstore/store_test.go96+++++++++++++++++
Msudoku/gamemode.go21+--
Msudokurgb/gamemode.go23++---
Mtakuzu/gamemode.go23++---
Mtakuzuplus/gamemode.go23++---
Mui/style.go9++
Mwordsearch/gamemode.go22++--
M app/game_session.go +2 -5
M app/handle_daily.go +4 -6
M app/handle_game.go +4 -4
M app/handle_menu.go +5 -0
M app/handle_seed.go +5 -9
@@ -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 }
M app/handle_stats.go +8 -12
@@ -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 }
M app/handle_theme.go +2 -3
M app/model.go +1 -0
A app/notice.go +54 -0
M app/spawn.go +7 -10
@@ -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)
M app/spawn_test.go +27 -0
M app/state_view.go +13 -21
@@ -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 }
M app/update_test.go +11 -1
M app/view.go +2 -2
M app/view_test.go +18 -0
M app/weekly.go +6 -5
@@ -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
M fillomino/gamemode.go +9 -15
M game/game_test.go +30 -0
M game/gamer.go +39 -0
M hashiwokakero/gamemode.go +9 -14
M hitori/gamemode.go +8 -13
M lightsout/gamemode.go +9 -14
M nonogram/gamemode.go +8 -13
M nurikabe/gamemode.go +9 -14
M pdfexport/parse.go +3 -11
A pdfexport/parse_handlers.go +31 -0
M registry/registry.go +24 -16
@@ -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 {
M registry/registry_test.go +21 -0
M rippleeffect/gamemode.go +9 -15
M shikaku/gamemode.go +9 -14
M spellpuzzle/gamemode.go +9 -13
M store/migrate.go +165 -0
@@ -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 {
M store/store.go +1 -29
M store/store_test.go +96 -0
@@ -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) {
M sudoku/gamemode.go +8 -13
M sudokurgb/gamemode.go +9 -14
M takuzu/gamemode.go +9 -14
M takuzuplus/gamemode.go +9 -14
M ui/style.go +9 -0
M wordsearch/gamemode.go +9 -13