clone

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

Summarize puzzle app updates

Dami committed 980850d
commit
980850de1275cc461079166154e95909e37830ec
parent
7df3e87262b51cb402cb85b23ca307f5f28d4369
author
Dami <dami.etoile@gmail.com>
86 files changed, +2935 -1881
Aapp/category_item.go46+++
Mapp/markdown_style_test.go9+-
Mapp/mode_display.go68++---
Mapp/mode_display_test.go45++-
Mapp/model.go17+-
Mapp/seed_input.go22+-
Mapp/update.go46+--
Mapp/update_test.go6+-
Mapp/view.go25+-
Aapp/view_test.go22+
Aarchitecture_test.go82+++++
Mcatalog/catalog.go212++++----------
Mcatalog/catalog_test.go106+------
Mcmd/continue.go4-
Mcmd/export_pdf.go22+-
Mcmd/new.go20+-
Mcmd/new_export.go36+-
Mcmd/root.go3-
Mdaily/daily.go8+-
Mfillomino/Gamemode.go2+-
Mfillomino/PrintAdapter.go3+-
Mfillomino/style.go26+--
Mgame/dynamic_grid.go26++
Mgame/dynamic_grid_test.go79+++++
Mgame/print_adapter.go55+---
Mgame/print_adapter_test.go22+-
Mgame/style.go11+
Mhashiwokakero/Gamemode.go2+-
Mhashiwokakero/PrintAdapter.go3+-
Mhashiwokakero/hashiwokakero_test.go57+++-
Mhashiwokakero/style.go25+-
Mhitori/Gamemode.go2+-
Mhitori/Model.go23+
Mhitori/PrintAdapter.go3+-
Mhitori/hitori_test.go205+++++++++++++
Ahitori/mouse.go36++
Mhitori/style.go225++++++++++-----
Mlightsout/Gamemode.go2+-
Mnonogram/Gamemode.go2+-
Mnonogram/PrintAdapter.go3+-
Mnurikabe/Gamemode.go2+-
Mnurikabe/Model.go33+-
Mnurikabe/PrintAdapter.go3+-
Mnurikabe/README.md5+-
Mnurikabe/help.md6+-
Mnurikabe/nurikabe_test.go357+++++++++++++++++++++--
Mnurikabe/solver.go17+
Mnurikabe/style.go138+++++++--
Mpdfexport/jsonl.go6+-
Mpdfexport/jsonl_test.go3+-
Apdfexport/print_adapter.go64++++
Mpdfexport/render.go9+-
Mpdfexport/render_error_test.go3+-
Apuzzle/puzzle.go47+++
Aregistry/registry.go220++++++++++++++
Mresolve/resolve.go101++----
Mresolve/resolve_test.go284++++++-------------
Mrippleeffect/Gamemode.go2+-
Mrippleeffect/PrintAdapter.go3+-
Mrippleeffect/style.go26+--
Msession/session.go15+-
Mshikaku/Gamemode.go2+-
Mshikaku/PrintAdapter.go3+-
Astats/layout.go56++++
Mstats/stats.go332+---------------------
Mstats/stats_test.go462+++---------------------------
Mstore/game_record.go14+
Astore/metadata.go93++++++
Astore/migrate.go142+++++++++
Mstore/stats.go116+++----
Mstore/store.go125+++++++-
Msudoku/PrintAdapter.go3+-
Msudoku/style.go26+--
Msudokurgb/PrintAdapter.go3+-
Asudokurgb/dynamic_board_test.go145+++++++++
Msudokurgb/style.go41++-
Mtakuzu/Gamemode.go2+-
Mtakuzu/PrintAdapter.go3+-
Mtakuzu/style.go21+-
Mtakuzu/takuzu_test.go74+++++
Mtakuzuplus/Gamemode.go2+-
Mtakuzuplus/PrintAdapter.go3+-
Aui/stats_view.go180++++++++++++
Mweekly/weekly.go8+-
Mwordsearch/Gamemode.go2+-
Mwordsearch/PrintAdapter.go3+-
A app/category_item.go +46 -0
M app/markdown_style_test.go +5 -4
M app/mode_display.go +27 -41
@@ -4,42 +4,37 @@ import (
44 "strings"55 66 "charm.land/bubbles/v2/list"7- "github.com/FelineStateMachine/puzzletea/game"7+ "github.com/FelineStateMachine/puzzletea/puzzle"8+ "github.com/FelineStateMachine/puzzletea/registry"89 )910 1011 type modeDisplayItem struct {11- item list.Item12+ mode registry.ModeEntry1213 title string1314 description string1415 filterValue string1516 }1617 17-func (i modeDisplayItem) Title() string { return i.title }18-func (i modeDisplayItem) Description() string { return i.description }19-func (i modeDisplayItem) FilterValue() string { return i.filterValue }20-func (i modeDisplayItem) Original() list.Item { return i.item }18+func (i modeDisplayItem) Title() string { return i.title }19+func (i modeDisplayItem) Description() string { return i.description }20+func (i modeDisplayItem) FilterValue() string { return i.filterValue }21+func (i modeDisplayItem) Original() registry.ModeEntry { return i.mode }2122 22-func buildModeDisplayItems(cat game.Category) []list.Item {23- titles := modeDisplayTitles(cat)24- items := make([]list.Item, 0, len(cat.Modes))25-26- for idx, item := range cat.Modes {27- mode, ok := item.(game.Mode)28- if !ok {29- items = append(items, item)30- continue31- }23+func buildModeDisplayItems(entry registry.Entry) []list.Item {24+ titles := modeDisplayTitles(entry)25+ items := make([]list.Item, 0, len(entry.Modes))3226 27+ for idx, mode := range entry.Modes {3328 displayTitle := titles[idx]34- filterValue := displayTitle + " " + mode.Description()35- if displayTitle != mode.Title() {36- filterValue += " " + mode.Title()29+ filterValue := displayTitle + " " + mode.Definition.Description30+ if displayTitle != mode.Definition.Title {31+ filterValue += " " + mode.Definition.Title3732 }3833 3934 items = append(items, modeDisplayItem{40- item: item,35+ mode: mode,4136 title: displayTitle,42- description: mode.Description(),37+ description: mode.Definition.Description,4338 filterValue: strings.TrimSpace(filterValue),4439 })4540 }
@@ -47,28 +42,19 @@ func buildModeDisplayItems(cat game.Category) []list.Item {
4742 return items4843 }4944 50-func modeDisplayTitles(cat game.Category) []string {51- counts := make(map[string]int, len(cat.Modes))52- titles := make([]string, 0, len(cat.Modes))45+func modeDisplayTitles(entry registry.Entry) []string {46+ counts := make(map[string]int, len(entry.Modes))47+ titles := make([]string, 0, len(entry.Modes))5348 54- for _, item := range cat.Modes {55- mode, ok := item.(game.Mode)56- if !ok {57- continue58- }59- base, _ := trimGridSizeSuffix(mode.Title())60- counts[game.NormalizeName(base)]++49+ for _, mode := range entry.Modes {50+ base, _ := trimGridSizeSuffix(mode.Definition.Title)51+ counts[puzzle.NormalizeName(base)]++6152 }6253 63- for _, item := range cat.Modes {64- mode, ok := item.(game.Mode)65- if !ok {66- continue67- }68-69- title := mode.Title()54+ for _, mode := range entry.Modes {55+ title := mode.Definition.Title7056 base, hadSize := trimGridSizeSuffix(title)71- if hadSize && counts[game.NormalizeName(base)] == 1 {57+ if hadSize && counts[puzzle.NormalizeName(base)] == 1 {7258 title = base7359 }7460 titles = append(titles, title)
@@ -77,8 +63,8 @@ func modeDisplayTitles(cat game.Category) []string {
7763 return titles7864 }7965 80-func unwrapModeDisplayItem(item list.Item) list.Item {81- displayItem, ok := item.(interface{ Original() list.Item })66+func unwrapModeDisplayItem(item list.Item) any {67+ displayItem, ok := item.(interface{ Original() registry.ModeEntry })8268 if !ok {8369 return item8470 }
M app/mode_display_test.go +24 -21
@@ -3,8 +3,9 @@ package app
33 import (44 "testing"55 6- "charm.land/bubbles/v2/list"76 "github.com/FelineStateMachine/puzzletea/game"7+ "github.com/FelineStateMachine/puzzletea/puzzle"8+ "github.com/FelineStateMachine/puzzletea/registry"89 )910 1011 type testMode struct {
@@ -18,15 +19,15 @@ func newTestMode(title string) testMode {
1819 func TestModeDisplayTitlesStripUniqueGridSizes(t *testing.T) {1920 t.Parallel()2021 21- cat := game.Category{22- Name: "Example",23- Modes: []list.Item{24- newTestMode("Mini 5x5"),25- newTestMode("Hard 10x10"),22+ entry := registry.Entry{23+ Definition: puzzle.Definition{Name: "Example"},24+ Modes: []registry.ModeEntry{25+ {Definition: puzzle.ModeDef{Title: "Mini 5x5", Description: "Mini 5x5 description"}},26+ {Definition: puzzle.ModeDef{Title: "Hard 10x10", Description: "Hard 10x10 description"}},2627 },2728 }2829 29- got := modeDisplayTitles(cat)30+ got := modeDisplayTitles(entry)3031 want := []string{"Mini", "Hard"}3132 3233 if len(got) != len(want) {
@@ -42,16 +43,16 @@ func TestModeDisplayTitlesStripUniqueGridSizes(t *testing.T) {
4243 func TestModeDisplayTitlesKeepDuplicateBaseNames(t *testing.T) {4344 t.Parallel()4445 45- cat := game.Category{46- Name: "Example",47- Modes: []list.Item{48- newTestMode("Easy 5x5"),49- newTestMode("Easy 10x10"),50- newTestMode("Hard 10x10"),46+ entry := registry.Entry{47+ Definition: puzzle.Definition{Name: "Example"},48+ Modes: []registry.ModeEntry{49+ {Definition: puzzle.ModeDef{Title: "Easy 5x5", Description: "Easy 5x5 description"}},50+ {Definition: puzzle.ModeDef{Title: "Easy 10x10", Description: "Easy 10x10 description"}},51+ {Definition: puzzle.ModeDef{Title: "Hard 10x10", Description: "Hard 10x10 description"}},5152 },5253 }5354 54- got := modeDisplayTitles(cat)55+ got := modeDisplayTitles(entry)5556 want := []string{"Easy 5x5", "Easy 10x10", "Hard"}5657 5758 if len(got) != len(want) {
@@ -68,12 +69,14 @@ func TestBuildModeDisplayItemsPreservesOriginalMode(t *testing.T) {
6869 t.Parallel()6970 7071 original := newTestMode("Hard 10x10")71- cat := game.Category{72- Name: "Example",73- Modes: []list.Item{original},72+ entry := registry.Entry{73+ Definition: puzzle.Definition{Name: "Example"},74+ Modes: []registry.ModeEntry{{75+ Definition: puzzle.ModeDef{Title: original.Title(), Description: original.Description()},76+ }},7477 }7578 76- items := buildModeDisplayItems(cat)79+ items := buildModeDisplayItems(entry)7780 if len(items) != 1 {7881 t.Fatalf("len(buildModeDisplayItems()) = %d, want 1", len(items))7982 }
@@ -86,11 +89,11 @@ func TestBuildModeDisplayItemsPreservesOriginalMode(t *testing.T) {
8689 t.Fatalf("display item Title() = %q, want %q", got, "Hard")8790 }8891 89- mode, ok := unwrapModeDisplayItem(items[0]).(game.Mode)92+ mode, ok := unwrapModeDisplayItem(items[0]).(registry.ModeEntry)9093 if !ok {91- t.Fatal("unwrapModeDisplayItem() did not return a game.Mode")94+ t.Fatal("unwrapModeDisplayItem() did not return a registry.ModeEntry")9295 }93- if got := mode.Title(); got != "Hard 10x10" {96+ if got := mode.Definition.Title; got != "Hard 10x10" {9497 t.Fatalf("unwrapped mode Title() = %q, want %q", got, "Hard 10x10")9598 }9699 }
M app/model.go +7 -10
@@ -7,9 +7,9 @@ import (
77 "strconv"88 "time"99 10- "github.com/FelineStateMachine/puzzletea/catalog"1110 "github.com/FelineStateMachine/puzzletea/config"1211 "github.com/FelineStateMachine/puzzletea/game"12+ "github.com/FelineStateMachine/puzzletea/registry"1313 "github.com/FelineStateMachine/puzzletea/stats"1414 "github.com/FelineStateMachine/puzzletea/store"1515 "github.com/FelineStateMachine/puzzletea/theme"
@@ -26,11 +26,8 @@ import (
2626 "github.com/charmbracelet/glamour"2727 )2828 29-// Categories is the typed catalog view used by stats and print-export helpers.30-var Categories = catalog.Categories()31-32-// GameCategories is the list.Item view of Categories for Bubble Tea lists.33-var GameCategories = catalog.CategoryItems()29+var gameCategoryItems = buildCategoryItems()30+var GameCategories = gameCategoryItems3431 3532 type viewState int3633
@@ -70,7 +67,7 @@ type navigationState struct {
7067 gameSelectList list.Model7168 categoryDetail viewport.Model7269 modeSelectList list.Model73- selectedCategory game.Category70+ selectedCategory registry.Entry7471 selectedModeTitle string7572 continueTable table.Model7673 continueGames []store.GameRecord
@@ -98,7 +95,7 @@ type sessionState struct {
9895 }9996 10097 type helpState struct {101- category game.Category98+ category registry.Entry10299 viewport viewport.Model103100 renderer *glamour.TermRenderer104101 rendererWidth int
@@ -187,7 +184,7 @@ func newSpinner() spinner.Model {
187184 // InitialModel creates the root TUI model for the main menu.188185 func InitialModel(s *store.Store, cfg *config.Config) model {189186 r := initDebugRenderer()190- l := ui.InitCategoryList(GameCategories, "Select Category")187+ l := ui.InitCategoryList(gameCategoryItems, "Select Category")191188 mm := ui.NewMainMenu(mainMenuItems)192189 return model{193190 state: mainMenuView,
@@ -203,7 +200,7 @@ func InitialModel(s *store.Store, cfg *config.Config) model {
203200 // bypassing the menu. Used by CLI flags (--new, --continue).204201 func InitialModelWithGame(s *store.Store, cfg *config.Config, g game.Gamer, activeGameID int64, completionSaved bool) model {205202 r := initDebugRenderer()206- l := ui.InitCategoryList(GameCategories, "Select Category")203+ l := ui.InitCategoryList(gameCategoryItems, "Select Category")207204 mm := ui.NewMainMenu(mainMenuItems)208205 return model{209206 state: gameView,
M app/seed_input.go +11 -11
@@ -3,8 +3,8 @@ package app
33 import (44 "strings"55 6- "github.com/FelineStateMachine/puzzletea/catalog"7- "github.com/FelineStateMachine/puzzletea/game"6+ "github.com/FelineStateMachine/puzzletea/puzzle"7+ "github.com/FelineStateMachine/puzzletea/registry"88 "github.com/FelineStateMachine/puzzletea/theme"99 "github.com/FelineStateMachine/puzzletea/ui"1010
@@ -15,7 +15,7 @@ import (
1515 1616 const randomSeedModeLabel = "Random"1717 18-func buildSeedModeOptions(definitions []game.Definition) []seedModeOption {18+func buildSeedModeOptions(definitions []puzzle.Definition) []seedModeOption {1919 options := []seedModeOption{{label: randomSeedModeLabel}}2020 2121 for _, def := range definitions {
@@ -23,8 +23,8 @@ func buildSeedModeOptions(definitions []game.Definition) []seedModeOption {
2323 continue2424 }2525 26- for _, item := range def.Modes {27- if _, ok := item.(game.SeededSpawner); !ok {26+ for _, mode := range def.Modes {27+ if !mode.Seeded {2828 continue2929 }3030
@@ -42,14 +42,14 @@ func buildSeedModeOptions(definitions []game.Definition) []seedModeOption {
4242 4343 func seedModeKey(gameType, modeTitle string) string {4444 if modeTitle == "" {45- return game.NormalizeName(gameType)45+ return puzzle.NormalizeName(gameType)4646 }47- return game.NormalizeName(gameType) + "::" + game.NormalizeName(modeTitle)47+ return puzzle.NormalizeName(gameType) + "::" + puzzle.NormalizeName(modeTitle)4848 }4949 50-func definitionHasSeededModes(def game.Definition) bool {51- for _, item := range def.Modes {52- if _, ok := item.(game.SeededSpawner); ok {50+func definitionHasSeededModes(def puzzle.Definition) bool {51+ for _, mode := range def.Modes {52+ if mode.Seeded {5353 return true5454 }5555 }
@@ -74,7 +74,7 @@ func (m model) enterSeedInputView() (model, tea.Cmd) {
7474 ti.CharLimit = 647575 ti.SetWidth(min(m.width, 48))7676 77- options := buildSeedModeOptions(catalog.All)77+ options := buildSeedModeOptions(registry.Definitions())7878 index := findSeedModeIndex(options, m.nav.lastSeedModeKey)7979 8080 m.nav.seedInput = ti
M app/update.go +22 -24
@@ -4,10 +4,10 @@ import (
44 "log"55 "time"66 7- "github.com/FelineStateMachine/puzzletea/catalog"87 "github.com/FelineStateMachine/puzzletea/config"98 "github.com/FelineStateMachine/puzzletea/daily"109 "github.com/FelineStateMachine/puzzletea/game"10+ "github.com/FelineStateMachine/puzzletea/registry"1111 "github.com/FelineStateMachine/puzzletea/resolve"1212 sessionflow "github.com/FelineStateMachine/puzzletea/session"1313 "github.com/FelineStateMachine/puzzletea/stats"
@@ -98,11 +98,11 @@ func categoryPickerSize(width, height int) categoryPickerMetrics {
9898 }9999 100100 func selectedCategoryName(item list.Item) string {101- cat, ok := item.(game.Category)101+ entry, ok := selectedCategoryEntry(item)102102 if !ok {103103 return ""104104 }105- return cat.Name105+ return entry.Definition.Name106106 }107107 108108 func activeFilterList(m model) *list.Model {
@@ -131,13 +131,13 @@ func (m model) updateCategoryDetailViewport() model {
131131 m.nav.categoryDetail.SetHeight(contentHeight)132132 m.nav.categoryDetail.FillHeight = true133133 134- cat, ok := m.nav.gameSelectList.SelectedItem().(game.Category)134+ entry, ok := selectedCategoryEntry(m.nav.gameSelectList.SelectedItem())135135 if !ok {136136 m.nav.categoryDetail.SetContent("")137137 return m138138 }139139 140- m.nav.categoryDetail.SetContent(renderCategoryDetailContent(cat, contentWidth))140+ m.nav.categoryDetail.SetContent(renderCategoryDetailContent(entry, contentWidth))141141 m.nav.categoryDetail.GotoTop()142142 return m143143 }
@@ -186,7 +186,7 @@ func (m model) updateStatsViewport() model {
186186 statsWidth, statsHeight := statsViewportSize(m.width, m.height, m.stats.cards)187187 m.stats.viewport.SetWidth(statsWidth)188188 m.stats.viewport.SetHeight(statsHeight)189- m.stats.viewport.SetContent(stats.RenderCardGrid(m.stats.cards, statsWidth))189+ m.stats.viewport.SetContent(ui.RenderStatsCardGrid(m.stats.cards, statsWidth))190190 return m191191 }192192
@@ -507,7 +507,7 @@ func (m model) handleOptionsMenuEnter() (tea.Model, tea.Cmd) {
507507 case "Theme":508508 return m.handleThemeEnter()509509 case "Guides":510- m.nav.helpSelectList = ui.InitList(GameCategories, "How to Play")510+ m.nav.helpSelectList = ui.InitList(gameCategoryItems, "How to Play")511511 m.nav.helpSelectList.SetSize(min(m.width, 64), min(m.height, ui.ListHeight(m.nav.helpSelectList)))512512 m.state = helpSelectView513513 }
@@ -546,13 +546,13 @@ func (m model) handleSeedConfirm() (tea.Model, tea.Cmd) {
546546 var gameType string547547 modeTitle := ""548548 if selectedMode.key == "" {549- spawner, gameType, modeTitle, err = resolve.SeededMode(seed, catalog.All)549+ spawner, gameType, modeTitle, err = resolve.SeededMode(seed, registry.Entries())550550 if err != nil {551551 log.Printf("failed to select seeded mode: %v", err)552552 return m, nil553553 }554554 } else {555- spawner, gameType, modeTitle, err = resolve.SeededModeForGame(seed, selectedMode.gameType, catalog.All)555+ spawner, gameType, modeTitle, err = resolve.SeededModeForGame(seed, selectedMode.gameType, registry.Entries())556556 if err != nil {557557 log.Printf("failed to select seeded mode for %s: %v", selectedMode.gameType, err)558558 return m, nil
@@ -614,12 +614,12 @@ func (m model) handleDailyPuzzle() (tea.Model, tea.Cmd) {
614614 }615615 616616 func (m model) handleGameSelectEnter() (tea.Model, tea.Cmd) {617- cat, ok := m.nav.gameSelectList.SelectedItem().(game.Category)617+ entry, ok := selectedCategoryEntry(m.nav.gameSelectList.SelectedItem())618618 if !ok {619619 return m, nil620620 }621- m.nav.selectedCategory = cat622- m.nav.modeSelectList = ui.InitList(buildModeDisplayItems(cat), cat.Name+" - Select Mode")621+ m.nav.selectedCategory = entry622+ m.nav.modeSelectList = ui.InitList(buildModeDisplayItems(entry), entry.Definition.Name+" - Select Mode")623623 m.nav.modeSelectList.SetSize(min(m.width, 64), min(m.height, ui.ListHeight(m.nav.modeSelectList)))624624 m.state = modeSelectView625625 return m, nil
@@ -627,26 +627,22 @@ func (m model) handleGameSelectEnter() (tea.Model, tea.Cmd) {
627627 628628 func (m model) handleModeSelectEnter() (tea.Model, tea.Cmd) {629629 item := unwrapModeDisplayItem(m.nav.modeSelectList.SelectedItem())630- mode, ok := item.(game.Mode)630+ mode, ok := item.(registry.ModeEntry)631631 if !ok {632632 return m, nil633633 }634- spawner, ok := item.(game.Spawner)635- if !ok {636- return m, nil637- }638- m.nav.selectedModeTitle = mode.Title()634+ m.nav.selectedModeTitle = mode.Definition.Title639635 ctx, jobID := m.beginSpawnContext()640636 m.session.spawn = &spawnRequest{641637 source: spawnSourceNormal,642638 name: sessionflow.GenerateUniqueName(m.store),643- gameType: m.nav.selectedCategory.Name,639+ gameType: m.nav.selectedCategory.Definition.Name,644640 modeTitle: m.nav.selectedModeTitle,645641 returnState: modeSelectView,646642 exitState: mainMenuView,647643 }648644 m.state = generatingView649- return m, tea.Batch(m.spinner.Tick, spawnCmd(spawner, ctx, jobID))645+ return m, tea.Batch(m.spinner.Tick, spawnCmd(mode.Spawner, ctx, jobID))650646 }651647 652648 func (m model) handleContinueEnter() (tea.Model, tea.Cmd) {
@@ -660,11 +656,11 @@ func (m model) handleContinueEnter() (tea.Model, tea.Cmd) {
660656 }661657 662658 func (m model) handleHelpSelectEnter() (tea.Model, tea.Cmd) {663- cat, ok := m.nav.helpSelectList.SelectedItem().(game.Category)659+ entry, ok := selectedCategoryEntry(m.nav.helpSelectList.SelectedItem())664660 if !ok {665661 return m, nil666662 }667- m.help.category = cat663+ m.help.category = entry668664 m = m.updateHelpDetailViewport()669665 m.state = helpDetailView670666 return m, nil
@@ -785,10 +781,12 @@ func (m model) handleStatsEnter() (tea.Model, tea.Cmd) {
785781 currentDaily = rec != nil786782 }787783 788- m.stats.cards = stats.BuildCards(catStats, modeStats)784+ weights := stats.WeightsFromDefinitions(registry.Definitions())785+ m.stats.cards = stats.BuildCards(weights, catStats, modeStats)789786 m.stats.profile = stats.BuildProfileBanner(790787 catStats,791788 modeStats,789+ weights,792790 streakDates,793791 currentDaily,794792 weekliesCompleted,
@@ -800,7 +798,7 @@ func (m model) handleStatsEnter() (tea.Model, tea.Cmd) {
800798 viewport.WithWidth(statsWidth),801799 viewport.WithHeight(statsHeight),802800 )803- m.stats.viewport.SetContent(stats.RenderCardGrid(m.stats.cards, statsWidth))801+ m.stats.viewport.SetContent(ui.RenderStatsCardGrid(m.stats.cards, statsWidth))804802 m.state = statsView805803 return m, nil806804 }
M app/update_test.go +3 -3
M app/view.go +12 -13
@@ -4,8 +4,7 @@ import (
44 "fmt"55 "strings"66 7- "github.com/FelineStateMachine/puzzletea/game"8- "github.com/FelineStateMachine/puzzletea/stats"7+ "github.com/FelineStateMachine/puzzletea/registry"98 "github.com/FelineStateMachine/puzzletea/theme"109 "github.com/FelineStateMachine/puzzletea/ui"1110
@@ -36,7 +35,7 @@ func (m model) View() tea.View {
3635 content = m.gameSelectViewContent()3736 case modeSelectView:3837 panel := ui.Panel(39- m.nav.selectedCategory.Name+" — Select Mode",38+ m.nav.selectedCategory.Definition.Name+" — Select Mode",4039 m.nav.modeSelectList.View(),4140 "↑/↓ navigate • enter select • esc back",4241 )
@@ -90,7 +89,7 @@ func (m model) View() tea.View {
9089 content = ui.CenterView(m.width, m.height, panel)9190 case helpDetailView:9291 panel := ui.Panel(93- m.help.category.Name+" — Guide",92+ m.help.category.Definition.Name+" — Guide",9493 m.help.viewport.View(),9594 "↑/↓ scroll • esc back",9695 )
@@ -103,7 +102,7 @@ func (m model) View() tea.View {
103102 if len(m.stats.cards) == 0 {104103 statsBody = m.stats.viewport.View()105104 } else {106- banner := stats.RenderBanner(m.stats.profile, statsWidth)105+ banner := ui.RenderStatsBanner(m.stats.profile, statsWidth)107106 statsBody = lipgloss.JoinVertical(lipgloss.Left,108107 banner,109108 "",
@@ -207,32 +206,32 @@ func categoryPickerListHeight() int {
207206 return maxVisibleItems + filterChrome208207 }209208 210-func renderCategoryDetailContent(cat game.Category, width int) string {209+func renderCategoryDetailContent(entry registry.Entry, width int) string {211210 p := theme.Current()212211 desc := lipgloss.NewStyle().213212 Foreground(p.FG).214213 Width(width).215- Render(cat.Desc)214+ Render(entry.Definition.Description)216215 217- meta := ui.DimItemStyle().Render(fmt.Sprintf("%d modes available", len(cat.Modes)))216+ meta := ui.DimItemStyle().Render(fmt.Sprintf("%d modes available", len(entry.Modes)))218217 content := lipgloss.JoinVertical(lipgloss.Left,219- ui.PanelTitle().Render(cat.Name),218+ ui.PanelTitle().Render(entry.Definition.Name),220219 meta,221220 "",222221 desc,223222 "",224223 ui.SelectedItemStyle().Render("Modes"),225- renderModeList(cat, width),224+ renderModeList(entry, width),226225 )227226 return content228227 }229228 230-func renderModeList(cat game.Category, width int) string {231- if len(cat.Modes) == 0 {229+func renderModeList(entry registry.Entry, width int) string {230+ if len(entry.Modes) == 0 {232231 return ui.DimItemStyle().Render("No modes available.")233232 }234233 235- displayTitles := modeDisplayTitles(cat)234+ displayTitles := modeDisplayTitles(entry)236235 modeNames := make([]string, 0, len(displayTitles))237236 for _, title := range displayTitles {238237 modeNames = append(modeNames, "• "+title)
A app/view_test.go +22 -0
A architecture_test.go +82 -0
@@ -0,0 +1,82 @@
1+package main_test2+3+import (4+ "go/parser"5+ "go/token"6+ "path/filepath"7+ "strings"8+ "testing"9+)10+11+func TestPuzzlePackageStaysPure(t *testing.T) {12+ assertPackageDoesNotImport(t, "puzzle", []string{13+ "charm.land/bubbletea",14+ "charm.land/bubbles",15+ "charm.land/lipgloss",16+ "github.com/FelineStateMachine/puzzletea/theme",17+ "github.com/FelineStateMachine/puzzletea/pdfexport",18+ })19+}20+21+func TestStatsPackageAvoidsRenderingImports(t *testing.T) {22+ assertPackageDoesNotImport(t, "stats", []string{23+ "charm.land/bubbletea",24+ "charm.land/bubbles",25+ "charm.land/lipgloss",26+ "github.com/FelineStateMachine/puzzletea/theme",27+ "github.com/FelineStateMachine/puzzletea/ui",28+ })29+}30+31+func TestStorePackageDoesNotImportSchedulePackages(t *testing.T) {32+ assertPackageDoesNotImport(t, "store", []string{33+ "github.com/FelineStateMachine/puzzletea/daily",34+ "github.com/FelineStateMachine/puzzletea/weekly",35+ })36+}37+38+func TestCatalogPackageDoesNotImportConcreteGames(t *testing.T) {39+ assertPackageDoesNotImport(t, "catalog", []string{40+ "github.com/FelineStateMachine/puzzletea/fillomino",41+ "github.com/FelineStateMachine/puzzletea/hashiwokakero",42+ "github.com/FelineStateMachine/puzzletea/hitori",43+ "github.com/FelineStateMachine/puzzletea/lightsout",44+ "github.com/FelineStateMachine/puzzletea/nonogram",45+ "github.com/FelineStateMachine/puzzletea/nurikabe",46+ "github.com/FelineStateMachine/puzzletea/rippleeffect",47+ "github.com/FelineStateMachine/puzzletea/shikaku",48+ "github.com/FelineStateMachine/puzzletea/sudoku",49+ "github.com/FelineStateMachine/puzzletea/sudokurgb",50+ "github.com/FelineStateMachine/puzzletea/takuzu",51+ "github.com/FelineStateMachine/puzzletea/takuzuplus",52+ "github.com/FelineStateMachine/puzzletea/wordsearch",53+ })54+}55+56+func assertPackageDoesNotImport(t *testing.T, dir string, forbidden []string) {57+ t.Helper()58+59+ files, err := filepath.Glob(filepath.Join(dir, "*.go"))60+ if err != nil {61+ t.Fatalf("glob %s: %v", dir, err)62+ }63+64+ fset := token.NewFileSet()65+ for _, path := range files {66+ if strings.HasSuffix(path, "_test.go") {67+ continue68+ }69+ file, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly)70+ if err != nil {71+ t.Fatalf("parse %s: %v", path, err)72+ }73+ for _, imp := range file.Imports {74+ value := strings.Trim(imp.Path.Value, `"`)75+ for _, prefix := range forbidden {76+ if strings.HasPrefix(value, prefix) {77+ t.Fatalf("%s imports forbidden package %q", path, value)78+ }79+ }80+ }81+ }82+}
M catalog/catalog.go +60 -152
@@ -3,102 +3,65 @@ package catalog
33 import (44 "fmt"55 6- "charm.land/bubbles/v2/list"7- "github.com/FelineStateMachine/puzzletea/fillomino"8- "github.com/FelineStateMachine/puzzletea/game"9- "github.com/FelineStateMachine/puzzletea/hashiwokakero"10- "github.com/FelineStateMachine/puzzletea/hitori"11- "github.com/FelineStateMachine/puzzletea/lightsout"12- "github.com/FelineStateMachine/puzzletea/nonogram"13- "github.com/FelineStateMachine/puzzletea/nurikabe"14- "github.com/FelineStateMachine/puzzletea/rippleeffect"15- "github.com/FelineStateMachine/puzzletea/shikaku"16- "github.com/FelineStateMachine/puzzletea/sudoku"17- "github.com/FelineStateMachine/puzzletea/sudokurgb"18- "github.com/FelineStateMachine/puzzletea/takuzu"19- "github.com/FelineStateMachine/puzzletea/takuzuplus"20- "github.com/FelineStateMachine/puzzletea/wordsearch"6+ "github.com/FelineStateMachine/puzzletea/puzzle"217 )228 23-// All is the explicit ordered catalog of every puzzle game.24-var All = []game.Definition{25- fillomino.Definition,26- hashiwokakero.Definition,27- hitori.Definition,28- lightsout.Definition,29- nonogram.Definition,30- nurikabe.Definition,31- rippleeffect.Definition,32- shikaku.Definition,33- sudoku.Definition,34- sudokurgb.Definition,35- takuzu.Definition,36- takuzuplus.Definition,37- wordsearch.Definition,38-}39-40-// DailyEntry is a flattened seeded daily candidate with metadata.419 type DailyEntry struct {42- Spawner game.SeededSpawner43- GameType string10+ GameID puzzle.GameID11+ GameName string12+ ModeID puzzle.ModeID4413 Mode string4514 }4615 47-type index struct {48- categories []game.Category49- items []list.Item50- names []string51- dailies []DailyEntry52- byName map[string]game.Definition53- byAlias map[string]string16+type Index struct {17+ definitions []puzzle.Definition18+ names []string19+ dailies []DailyEntry20+ byName map[string]puzzle.Definition21+ byAlias map[string]string5422 }5523 56-var built = mustBuildIndex(All)57-58-func mustBuildIndex(definitions []game.Definition) index {59- idx, err := buildIndex(definitions)24+func MustBuild(definitions []puzzle.Definition) Index {25+ idx, err := Build(definitions)6026 if err != nil {6127 panic(err)6228 }6329 return idx6430 }6531 66-func buildIndex(definitions []game.Definition) (index, error) {32+func Build(definitions []puzzle.Definition) (Index, error) {6733 if err := Validate(definitions); err != nil {68- return index{}, err34+ return Index{}, err6935 }7036 71- idx := index{72- categories: make([]game.Category, 0, len(definitions)),73- items: make([]list.Item, 0, len(definitions)),74- names: make([]string, 0, len(definitions)),75- dailies: make([]DailyEntry, 0, len(definitions)*2),76- byName: make(map[string]game.Definition, len(definitions)),77- byAlias: make(map[string]string),37+ idx := Index{38+ definitions: append([]puzzle.Definition(nil), definitions...),39+ names: make([]string, 0, len(definitions)),40+ dailies: make([]DailyEntry, 0, len(definitions)*2),41+ byName: make(map[string]puzzle.Definition, len(definitions)),42+ byAlias: make(map[string]string),7843 }7944 8045 for _, def := range definitions {81- category := def.Category()82- idx.categories = append(idx.categories, category)83- idx.items = append(idx.items, category)8446 idx.names = append(idx.names, def.Name)85- idx.byName[game.NormalizeName(def.Name)] = def47+ idx.byName[puzzle.NormalizeName(def.Name)] = def8648 for _, alias := range def.Aliases {87- idx.byAlias[game.NormalizeName(alias)] = def.Name49+ idx.byAlias[puzzle.NormalizeName(alias)] = def.Name8850 }89- for _, item := range def.DailyModes {90- mode, ok := item.(game.Mode)91- if !ok {92- continue93- }94- spawner, ok := item.(game.SeededSpawner)51+ byModeID := make(map[puzzle.ModeID]puzzle.ModeDef, len(def.Modes))52+ for _, mode := range def.Modes {53+ byModeID[mode.ID] = mode54+ }55+ for _, dailyID := range def.DailyModeIDs {56+ mode, ok := byModeID[dailyID]9557 if !ok {9658 continue9759 }9860 idx.dailies = append(idx.dailies, DailyEntry{99- Spawner: spawner,100- GameType: def.Name,101- Mode: mode.Title(),61+ GameID: def.ID,62+ GameName: def.Name,63+ ModeID: mode.ID,64+ Mode: mode.Title,10265 })10366 }10467 }
@@ -106,49 +69,39 @@ func buildIndex(definitions []game.Definition) (index, error) {
10669 return idx, nil10770 }10871 109-// Validate checks that the catalog is internally consistent.110-func Validate(definitions []game.Definition) error {72+func Validate(definitions []puzzle.Definition) error {11173 names := make(map[string]string, len(definitions))11274 aliases := make(map[string]string)11375 11476 for _, def := range definitions {115- if def.Import == nil {116- return fmt.Errorf("game %q has nil importer", def.Name)117- }118-119- normName := game.NormalizeName(def.Name)77+ normName := puzzle.NormalizeName(def.Name)12078 if normName == "" {12179 return fmt.Errorf("game %q has empty canonical name", def.Name)12280 }81+ if def.ID == "" {82+ return fmt.Errorf("game %q has empty id", def.Name)83+ }12384 if prior, exists := names[normName]; exists {12485 return fmt.Errorf("duplicate game name %q conflicts with %q", def.Name, prior)12586 }12687 names[normName] = def.Name12788 128- modeTitles := make(map[string]struct{}, len(def.Modes))129- for _, item := range def.Modes {130- mode, ok := item.(game.Mode)131- if !ok {132- continue89+ modeTitles := make(map[puzzle.ModeID]struct{}, len(def.Modes))90+ for _, mode := range def.Modes {91+ if mode.ID == "" {92+ return fmt.Errorf("game %q has mode %q with empty id", def.Name, mode.Title)13393 }134- modeTitles[mode.Title()] = struct{}{}94+ modeTitles[mode.ID] = struct{}{}13595 }13696 137- for _, item := range def.DailyModes {138- mode, ok := item.(game.Mode)139- if !ok {140- return fmt.Errorf("game %q has daily mode without game.Mode implementation", def.Name)141- }142- if _, ok := item.(game.SeededSpawner); !ok {143- return fmt.Errorf("game %q daily mode %q is not seeded", def.Name, mode.Title())144- }145- if _, ok := modeTitles[mode.Title()]; !ok {146- return fmt.Errorf("game %q daily mode %q is not present in Modes", def.Name, mode.Title())97+ for _, dailyID := range def.DailyModeIDs {98+ if _, ok := modeTitles[dailyID]; !ok {99+ return fmt.Errorf("game %q daily mode %q is not present in Modes", def.Name, dailyID)147100 }148101 }149102 150103 for _, alias := range def.Aliases {151- normAlias := game.NormalizeName(alias)104+ normAlias := puzzle.NormalizeName(alias)152105 if normAlias == "" {153106 return fmt.Errorf("game %q has empty alias", def.Name)154107 }
@@ -168,76 +121,31 @@ func Validate(definitions []game.Definition) error {
168121 return nil169122 }170123 171-// Names returns the canonical game names in catalog order.172-func Names() []string {173- return append([]string(nil), built.names...)174-}175-176-// Categories returns the category view of the catalog in menu order.177-func Categories() []game.Category {178- return append([]game.Category(nil), built.categories...)124+func (i Index) Names() []string {125+ return append([]string(nil), i.names...)179126 }180127 181-// CategoryItems returns the category view as list items for Bubble Tea lists.182-func CategoryItems() []list.Item {183- return append([]list.Item(nil), built.items...)128+func (i Index) Definitions() []puzzle.Definition {129+ return append([]puzzle.Definition(nil), i.definitions...)184130 }185131 186-// DailyEntries returns the flattened daily-eligible seeded modes.187-func DailyEntries() []DailyEntry {188- return append([]DailyEntry(nil), built.dailies...)132+func (i Index) DailyEntries() []DailyEntry {133+ return append([]DailyEntry(nil), i.dailies...)189134 }190135 191-// Lookup returns a definition by canonical name.192-func Lookup(name string) (game.Definition, bool) {193- def, ok := built.byName[game.NormalizeName(name)]136+func (i Index) Lookup(name string) (puzzle.Definition, bool) {137+ def, ok := i.byName[puzzle.NormalizeName(name)]194138 return def, ok195139 }196140 197-// Resolve returns a definition by canonical name or alias.198-func Resolve(name string) (game.Definition, bool) {199- norm := game.NormalizeName(name)200- if def, ok := built.byName[norm]; ok {141+func (i Index) Resolve(name string) (puzzle.Definition, bool) {142+ norm := puzzle.NormalizeName(name)143+ if def, ok := i.byName[norm]; ok {201144 return def, true202145 }203- canonical, ok := built.byAlias[norm]204- if !ok {205- return game.Definition{}, false206- }207- return Lookup(canonical)208-}209-210-// Category resolves a canonical game name or alias to the menu-facing category.211-func Category(name string) (game.Category, error) {212- def, ok := Resolve(name)213- if !ok {214- return game.Category{}, fmt.Errorf("unknown game %q\n\nAvailable games:\n %s",215- name, joinLines(Names()))216- }217- return def.Category(), nil218-}219-220-// Import reconstructs a saved game for the given canonical game type.221-func Import(gameType string, data []byte) (game.Gamer, error) {222- def, ok := Lookup(gameType)146+ canonical, ok := i.byAlias[norm]223147 if !ok {224- return nil, fmt.Errorf("unknown game type %q in save data", gameType)225- }226- g, err := def.Import(data)227- if err != nil {228- return nil, fmt.Errorf("failed to import game: %w", err)229- }230- return g, nil231-}232-233-func joinLines(values []string) string {234- if len(values) == 0 {235- return ""236- }237-238- result := values[0]239- for i := 1; i < len(values); i++ {240- result += "\n " + values[i]148+ return puzzle.Definition{}, false241149 }242- return result150+ return i.Lookup(canonical)243151 }
M catalog/catalog_test.go +20 -86
@@ -1,19 +1,21 @@
1-package catalog1+package catalog_test22 33 import (4- "strings"54 "testing"65 7- "github.com/FelineStateMachine/puzzletea/lightsout"6+ "github.com/FelineStateMachine/puzzletea/catalog"7+ "github.com/FelineStateMachine/puzzletea/registry"88 )99 10-func TestValidateAll(t *testing.T) {11- if err := Validate(All); err != nil {12- t.Fatalf("Validate(All) error = %v", err)10+func TestValidateDefinitions(t *testing.T) {11+ definitions := registry.Definitions()12+ if err := catalog.Validate(definitions); err != nil {13+ t.Fatalf("Validate(definitions) error = %v", err)1314 }1415 }1516 1617 func TestResolveSupportsCanonicalNamesAndAliases(t *testing.T) {18+ idx := catalog.MustBuild(registry.Definitions())1719 tests := []struct {1820 input string1921 want string
@@ -28,7 +30,7 @@ func TestResolveSupportsCanonicalNamesAndAliases(t *testing.T) {
2830 }2931 3032 for _, tt := range tests {31- def, ok := Resolve(tt.input)33+ def, ok := idx.Resolve(tt.input)3234 if !ok {3335 t.Fatalf("Resolve(%q) returned !ok", tt.input)3436 }
@@ -38,96 +40,28 @@ func TestResolveSupportsCanonicalNamesAndAliases(t *testing.T) {
3840 }3941 }4042 41-func TestCategorySupportsCanonicalNamesAndAliases(t *testing.T) {42- tests := []struct {43- input string44- want string45- }{46- {input: "Sudoku", want: "Sudoku"},47- {input: "sudoku-rgb", want: "Sudoku RGB"},48- {input: "lights-out", want: "Lights Out"},49- {input: "polyomino", want: "Fillomino"},50- {input: "wordsearch", want: "Word Search"},51- }52-53- for _, tt := range tests {54- cat, err := Category(tt.input)55- if err != nil {56- t.Fatalf("Category(%q) error = %v", tt.input, err)57- }58- if cat.Name != tt.want {59- t.Fatalf("Category(%q).Name = %q, want %q", tt.input, cat.Name, tt.want)60- }61- }62-}63-64-func TestCategoryUnknownGameIncludesAvailableGames(t *testing.T) {65- _, err := Category("chess")66- if err == nil {67- t.Fatal("Category(chess) error = nil, want non-nil")68- }69- if got := err.Error(); got == "" || !containsAll(got, []string{"unknown game", "Available games:", "Sudoku"}) {70- t.Fatalf("Category(chess) error = %q, want available games list", got)71- }72-}73-74-func TestCategoriesAndNamesPreserveCatalogOrder(t *testing.T) {75- names := Names()76- categories := Categories()77- if len(names) != len(All) {78- t.Fatalf("len(Names()) = %d, want %d", len(names), len(All))79- }80- if len(categories) != len(All) {81- t.Fatalf("len(Categories()) = %d, want %d", len(categories), len(All))82- }83- for i, def := range All {43+func TestDefinitionsAndNamesPreserveRegistryOrder(t *testing.T) {44+ definitions := registry.Definitions()45+ idx := catalog.MustBuild(definitions)46+ names := idx.Names()47+ if len(names) != len(definitions) {48+ t.Fatalf("len(Names()) = %d, want %d", len(names), len(definitions))49+ }50+ for i, def := range definitions {8451 if names[i] != def.Name {8552 t.Fatalf("Names()[%d] = %q, want %q", i, names[i], def.Name)8653 }87- if categories[i].Name != def.Name {88- t.Fatalf("Categories()[%d].Name = %q, want %q", i, categories[i].Name, def.Name)89- }90- }91-}92-93-func containsAll(s string, parts []string) bool {94- for _, part := range parts {95- if !strings.Contains(s, part) {96- return false97- }98- }99- return true100-}101-102-func TestImportRoundTrip(t *testing.T) {103- g, err := lightsout.New(3, 3)104- if err != nil {105- t.Fatal(err)106- }107- save, err := g.GetSave()108- if err != nil {109- t.Fatal(err)110- }111-112- loaded, err := Import("Lights Out", save)113- if err != nil {114- t.Fatalf("Import returned error: %v", err)115- }116- if loaded == nil {117- t.Fatal("Import returned nil game")11854 }11955 }12056 12157 func TestDailyEntriesHaveMetadata(t *testing.T) {122- entries := DailyEntries()58+ idx := catalog.MustBuild(registry.Definitions())59+ entries := idx.DailyEntries()12360 if len(entries) == 0 {12461 t.Fatal("DailyEntries returned no entries")12562 }12663 for i, entry := range entries {127- if entry.Spawner == nil {128- t.Fatalf("DailyEntries()[%d].Spawner is nil", i)129- }130- if entry.GameType == "" || entry.Mode == "" {64+ if entry.GameID == "" || entry.GameName == "" || entry.ModeID == "" || entry.Mode == "" {13165 t.Fatalf("DailyEntries()[%d] missing metadata", i)13266 }13367 }
M cmd/continue.go +0 -4
M cmd/export_pdf.go +9 -13
@@ -7,9 +7,9 @@ import (
77 "strings"88 "time"99 10- "github.com/FelineStateMachine/puzzletea/catalog"11- "github.com/FelineStateMachine/puzzletea/game"1210 "github.com/FelineStateMachine/puzzletea/pdfexport"11+ "github.com/FelineStateMachine/puzzletea/puzzle"12+ "github.com/FelineStateMachine/puzzletea/registry"1313 1414 "github.com/spf13/cobra"1515 )
@@ -53,7 +53,7 @@ func runExportPDF(cmd *cobra.Command, args []string) error {
5353 return nil5454 }5555 56- lookup := buildModeDifficultyLookup(catalog.Categories())56+ lookup := buildModeDifficultyLookup(registry.Definitions())5757 annotateDifficulty(puzzles, lookup)5858 5959 shuffleSeed := strings.TrimSpace(flagPDFShuffleSeed)
@@ -135,17 +135,13 @@ func buildRenderConfigForPDF(docs []pdfexport.PackDocument, shuffleSeed string,
135135 return cfg, nil136136 }137137 138-func buildModeDifficultyLookup(categories []game.Category) map[string]map[string]float64 {139- lookup := make(map[string]map[string]float64, len(categories))138+func buildModeDifficultyLookup(definitions []puzzle.Definition) map[string]map[string]float64 {139+ lookup := make(map[string]map[string]float64, len(definitions))140140 141- for _, cat := range categories {141+ for _, def := range definitions {142142 titles := []string{}143- for _, item := range cat.Modes {144- mode, ok := item.(game.Mode)145- if !ok {146- continue147- }148- title := strings.TrimSpace(mode.Title())143+ for _, mode := range def.Modes {144+ title := strings.TrimSpace(mode.Title)149145 if title == "" {150146 continue151147 }
@@ -165,7 +161,7 @@ func buildModeDifficultyLookup(categories []game.Category) map[string]map[string
165161 }166162 }167163 168- lookup[normalizeDifficultyToken(cat.Name)] = scores164+ lookup[normalizeDifficultyToken(def.Name)] = scores169165 }170166 171167 return lookup
M cmd/new.go +8 -12
@@ -4,12 +4,11 @@ import (
44 "fmt"55 "strings"66 7- "github.com/FelineStateMachine/puzzletea/catalog"87 "github.com/FelineStateMachine/puzzletea/config"98 "github.com/FelineStateMachine/puzzletea/game"9+ "github.com/FelineStateMachine/puzzletea/registry"1010 "github.com/FelineStateMachine/puzzletea/resolve"1111 sessionflow "github.com/FelineStateMachine/puzzletea/session"12- "github.com/FelineStateMachine/puzzletea/stats"1312 "github.com/FelineStateMachine/puzzletea/store"1413 1514 "github.com/spf13/cobra"
@@ -27,7 +26,7 @@ var newCmd = &cobra.Command{
2726 Short: "Start a new puzzle game",2827 Long: fmt.Sprintf("Start a new puzzle game, optionally specifying the difficulty mode.\n"+2928 "Use --set-seed to generate a deterministic puzzle from a seed string.\n\nAvailable games:\n %s",30- strings.Join(catalog.Names(), "\n ")),29+ strings.Join(registry.Names(), "\n ")),3130 Args: cobra.RangeArgs(0, 2),3231 RunE: func(cmd *cobra.Command, args []string) error {3332 if flagOutput != "" || cmd.Flags().Changed("export") {
@@ -63,12 +62,13 @@ func init() {
6362 6463 // launchNewGame resolves the game/mode, spawns a new game, and launches the TUI.6564 func launchNewGame(gameArg, modeArg, seed string, cfg *config.Config) error {66- cat, err := catalog.Category(gameArg)67- if err != nil {65+ entry, ok := registry.Resolve(gameArg)66+ if !ok {67+ err := fmt.Errorf("unknown game %q", gameArg)6868 return err6969 }7070 71- spawner, modeTitle, err := resolve.Mode(cat, modeArg)71+ spawner, modeTitle, err := resolve.Mode(entry, modeArg)7272 if err != nil {7373 return err7474 }
@@ -84,12 +84,10 @@ func launchNewGame(gameArg, modeArg, seed string, cfg *config.Config) error {
8484 }8585 defer s.Close()8686 87- stats.InitModeXP(catalog.Categories())88-8987 name := sessionflow.GenerateUniqueName(s)9088 g = g.SetTitle(name)9189 92- rec, err := sessionflow.CreateRecord(s, g, name, cat.Name, modeTitle)90+ rec, err := sessionflow.CreateRecord(s, g, name, entry.Definition.Name, modeTitle)9391 if err != nil {9492 return err9593 }
@@ -126,8 +124,6 @@ func launchSeededGame(seed string, cfg *config.Config) error {
126124 }127125 defer s.Close()128126 129- stats.InitModeXP(catalog.Categories())130-131127 // If a game with this name already exists, resume it (including132128 // abandoned games — seeded puzzles are deterministic and should133129 // always be resumable rather than duplicated).
@@ -146,7 +142,7 @@ func launchSeededGame(seed string, cfg *config.Config) error {
146142 }147143 148144 // Mode selection uses rendezvous hashing (independent of RNG).149- spawner, gameType, modeTitle, err := resolve.SeededMode(seed, catalog.All)145+ spawner, gameType, modeTitle, err := resolve.SeededMode(seed, registry.Entries())150146 if err != nil {151147 return err152148 }
M cmd/new_export.go +14 -22
@@ -10,10 +10,10 @@ import (
1010 "strings"1111 "time"1212 13- "github.com/FelineStateMachine/puzzletea/catalog"1413 "github.com/FelineStateMachine/puzzletea/game"1514 "github.com/FelineStateMachine/puzzletea/namegen"1615 "github.com/FelineStateMachine/puzzletea/pdfexport"16+ "github.com/FelineStateMachine/puzzletea/registry"1717 "github.com/FelineStateMachine/puzzletea/resolve"1818 1919 "github.com/spf13/cobra"
@@ -31,11 +31,11 @@ func runNewExport(cmd *cobra.Command, args []string) error {
3131 return err3232 }3333 34- cat, err := catalog.Category(args[0])35- if err != nil {36- return err34+ entry, ok := registry.Resolve(args[0])35+ if !ok {36+ return fmt.Errorf("unknown game %q", args[0])3737 }38- if !game.HasPrintAdapter(cat.Name) {38+ if !pdfexport.HasPrintAdapter(entry.Definition.Name) {3939 return nil4040 }4141
@@ -44,13 +44,13 @@ func runNewExport(cmd *cobra.Command, args []string) error {
4444 modeArg = args[1]4545 }4646 47- entries, modeSelection, err := collectExportModes(cat, modeArg)47+ entries, modeSelection, err := collectExportModes(entry, modeArg)4848 if err != nil {4949 return err5050 }5151 5252 generatedAt := exportNow()53- records, err := buildExportRecords(cat.Name, modeSelection, entries, flagExport, flagWithSeed, generatedAt)53+ records, err := buildExportRecords(entry.Definition.Name, modeSelection, entries, flagExport, flagWithSeed, generatedAt)5454 if err != nil {5555 return err5656 }
@@ -81,32 +81,24 @@ func validateNewExportFlags(cmd *cobra.Command, args []string) error {
8181 return nil8282 }8383 84-func collectExportModes(cat game.Category, modeArg string) ([]exportModeEntry, string, error) {84+func collectExportModes(entry registry.Entry, modeArg string) ([]exportModeEntry, string, error) {8585 if modeArg != "" {86- spawner, modeTitle, err := resolve.Mode(cat, modeArg)86+ spawner, modeTitle, err := resolve.Mode(entry, modeArg)8787 if err != nil {8888 return nil, "", err8989 }9090 return []exportModeEntry{{spawner: spawner, mode: modeTitle}}, modeTitle, nil9191 }9292 93- entries := make([]exportModeEntry, 0, len(cat.Modes))94- for _, item := range cat.Modes {95- mode, ok := item.(game.Mode)96- if !ok {97- continue98- }99- spawner, ok := item.(game.Spawner)100- if !ok {101- continue102- }93+ entries := make([]exportModeEntry, 0, len(entry.Modes))94+ for _, mode := range entry.Modes {10395 entries = append(entries, exportModeEntry{104- spawner: spawner,105- mode: mode.Title(),96+ spawner: mode.Spawner,97+ mode: mode.Definition.Title,10698 })10799 }108100 if len(entries) == 0 {109- return nil, "", fmt.Errorf("game %q has no exportable modes", cat.Name)101+ return nil, "", fmt.Errorf("game %q has no exportable modes", entry.Definition.Name)110102 }111103 112104 return entries, "mixed modes", nil
M cmd/root.go +0 -3
M daily/daily.go +4 -4
M fillomino/Gamemode.go +1 -1
M fillomino/PrintAdapter.go +1 -2
M fillomino/style.go +1 -25
M game/dynamic_grid.go +26 -0
M game/dynamic_grid_test.go +79 -0
@@ -1,6 +1,8 @@
11 package game22 33 import (4+ "image"5+ "image/color"46 "strings"57 "testing"68
@@ -230,6 +232,83 @@ func TestDynamicGridScreenToCell(t *testing.T) {
230232 }231233 }232234 235+func TestDynamicGridBridgeOnCrosshairAxis(t *testing.T) {236+ cursor := Cursor{X: 2, Y: 1}237+238+ tests := []struct {239+ name string240+ bridge DynamicGridBridge241+ want bool242+ }{243+ {244+ name: "vertical bridge matches cursor row via cells",245+ bridge: DynamicGridBridge{246+ Kind: DynamicGridBridgeVertical,247+ Cells: [4]image.Point{{X: 1, Y: 1}, {X: 2, Y: 1}},248+ Count: 2,249+ },250+ want: true,251+ },252+ {253+ name: "horizontal bridge matches cursor column via cells",254+ bridge: DynamicGridBridge{255+ Kind: DynamicGridBridgeHorizontal,256+ Cells: [4]image.Point{{X: 2, Y: 0}, {X: 2, Y: 1}},257+ Count: 2,258+ },259+ want: true,260+ },261+ {262+ name: "separator vertical bridge matches cursor row without cells",263+ bridge: DynamicGridBridge{264+ Kind: DynamicGridBridgeVertical,265+ Y: 1,266+ },267+ want: true,268+ },269+ {270+ name: "separator horizontal bridge matches cursor column without cells",271+ bridge: DynamicGridBridge{272+ Kind: DynamicGridBridgeHorizontal,273+ X: 2,274+ },275+ want: true,276+ },277+ {278+ name: "junction does not participate in axis highlighting",279+ bridge: DynamicGridBridge{280+ Kind: DynamicGridBridgeJunction,281+ Cells: [4]image.Point{{X: 2, Y: 1}},282+ Count: 1,283+ },284+ want: false,285+ },286+ }287+288+ for _, tt := range tests {289+ t.Run(tt.name, func(t *testing.T) {290+ if got := DynamicGridBridgeOnCrosshairAxis(cursor, tt.bridge); got != tt.want {291+ t.Fatalf("DynamicGridBridgeOnCrosshairAxis() = %v, want %v", got, tt.want)292+ }293+ })294+ }295+}296+297+func TestSameColor(t *testing.T) {298+ if !SameColor(color.NRGBA{R: 10, G: 20, B: 30, A: 40}, color.NRGBA{R: 10, G: 20, B: 30, A: 40}) {299+ t.Fatal("expected identical colors to match")300+ }301+ if SameColor(color.NRGBA{R: 10, G: 20, B: 30, A: 40}, color.NRGBA{R: 10, G: 20, B: 30, A: 41}) {302+ t.Fatal("expected different colors not to match")303+ }304+ if !SameColor(nil, nil) {305+ t.Fatal("expected nil colors to match")306+ }307+ if SameColor(color.NRGBA{R: 10, G: 20, B: 30, A: 40}, nil) {308+ t.Fatal("expected color and nil not to match")309+ }310+}311+233312 func TestDynamicGridOriginStableAcrossSecondaryRows(t *testing.T) {234313 grid := RenderDynamicGrid(DynamicGridSpec{235314 Width: 3,
M game/print_adapter.go +6 -49
@@ -1,64 +1,21 @@
11 package game22 3-import (4- "reflect"5- "strings"3+import "github.com/FelineStateMachine/puzzletea/pdfexport"64 7- "codeberg.org/go-pdf/fpdf"8-)9-10-type PrintAdapter interface {11- CanonicalGameType() string12- Aliases() []string13- BuildPDFPayload(save []byte) (any, error)14- RenderPDFBody(pdf *fpdf.Fpdf, payload any) error15-}16-17-var printAdapterRegistry = map[string]PrintAdapter{}5+type PrintAdapter = pdfexport.PrintAdapter186 197 func RegisterPrintAdapter(adapter PrintAdapter) {20- canonical := normalizeGameTypeToken(adapter.CanonicalGameType())21- if canonical == "" {22- return23- }24-25- printAdapterRegistry[canonical] = adapter26- for _, alias := range adapter.Aliases() {27- normalized := normalizeGameTypeToken(alias)28- if normalized == "" {29- continue30- }31- printAdapterRegistry[normalized] = adapter32- }8+ pdfexport.RegisterPrintAdapter(adapter)339 }3410 3511 func LookupPrintAdapter(gameType string) (PrintAdapter, bool) {36- adapter, ok := printAdapterRegistry[normalizeGameTypeToken(gameType)]37- return adapter, ok12+ return pdfexport.LookupPrintAdapter(gameType)3813 }3914 4015 func HasPrintAdapter(gameType string) bool {41- _, ok := LookupPrintAdapter(gameType)42- return ok16+ return pdfexport.HasPrintAdapter(gameType)4317 }4418 4519 func IsNilPrintPayload(payload any) bool {46- if payload == nil {47- return true48- }49-50- v := reflect.ValueOf(payload)51- switch v.Kind() {52- case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:53- return v.IsNil()54- default:55- return false56- }57-}58-59-func normalizeGameTypeToken(s string) string {60- s = strings.ToLower(strings.TrimSpace(s))61- s = strings.ReplaceAll(s, "-", " ")62- s = strings.ReplaceAll(s, "_", " ")63- return strings.Join(strings.Fields(s), " ")20+ return pdfexport.IsNilPrintPayload(payload)6421 }
M game/print_adapter_test.go +7 -15
@@ -17,23 +17,19 @@ func (a testPrintAdapter) BuildPDFPayload([]byte) (any, error) { return nil, nil
1717 func (a testPrintAdapter) RenderPDFBody(*fpdf.Fpdf, any) error { return nil }1818 1919 func TestPrintAdapterRegistryLookupAndAliases(t *testing.T) {20- original := printAdapterRegistry21- printAdapterRegistry = map[string]PrintAdapter{}22- t.Cleanup(func() { printAdapterRegistry = original })23-2420 adapter := testPrintAdapter{25- canonical: "Word Search",26- aliases: []string{"wordsearch", "word-search"},21+ canonical: "Test Word Search",22+ aliases: []string{"testwordsearch", "test-word-search"},2723 }2824 RegisterPrintAdapter(adapter)2925 30- if !HasPrintAdapter("Word Search") {26+ if !HasPrintAdapter("Test Word Search") {3127 t.Fatal("expected canonical lookup to be supported")3228 }33- if !HasPrintAdapter("word_search") {29+ if !HasPrintAdapter("test_word_search") {3430 t.Fatal("expected underscore alias lookup to be supported")3531 }36- if !HasPrintAdapter("wordsearch") {32+ if !HasPrintAdapter("testwordsearch") {3733 t.Fatal("expected compact alias lookup to be supported")3834 }3935 if HasPrintAdapter("lights out") {
@@ -42,13 +38,9 @@ func TestPrintAdapterRegistryLookupAndAliases(t *testing.T) {
4238 }4339 4440 func TestRegisterPrintAdapterSkipsBlankCanonical(t *testing.T) {45- original := printAdapterRegistry46- printAdapterRegistry = map[string]PrintAdapter{}47- t.Cleanup(func() { printAdapterRegistry = original })48-4941 RegisterPrintAdapter(testPrintAdapter{canonical: " "})50- if len(printAdapterRegistry) != 0 {51- t.Fatalf("registry size = %d, want 0", len(printAdapterRegistry))42+ if HasPrintAdapter("should-not-exist") {43+ t.Fatal("blank canonical adapter should not create lookups")5244 }5345 }5446
M game/style.go +11 -0
M hashiwokakero/Gamemode.go +1 -1
M hashiwokakero/PrintAdapter.go +1 -2
M hashiwokakero/hashiwokakero_test.go +50 -7
@@ -576,7 +576,37 @@ func TestResolveCellVisualSatisfiedAndOverfilledIslands(t *testing.T) {
576576 }577577 }578578 579-func TestSolvedBridgeVisualStaysLegibleOverBlueBoard(t *testing.T) {579+func TestResolveCellVisualSolvedIsland(t *testing.T) {580+ p := Puzzle{581+ Width: 1,582+ Height: 1,583+ Islands: []Island{584+ {ID: 0, X: 0, Y: 0, Required: 1},585+ },586+ }587+ m := Model{puzzle: p, cursorIsland: 0, keys: DefaultKeyMap}588+589+ unsolved := resolveCellVisual(m, 0, 0, false)590+ solved := resolveCellVisual(m, 0, 0, true)591+592+ if sameColor(unsolved.bg, solved.bg) {593+ t.Fatal("expected solved island background to differ from unsolved island background")594+ }595+596+ got := renderCellVisual(solved)597+ want := renderExpectedIslandCellWithOuterBG(598+ " 1 ",599+ theme.Current().SolvedFG,600+ solvedIslandBackground(),601+ solvedBoardBackground(),602+ true,603+ )604+ if got != want {605+ t.Fatalf("solved island render = %q, want %q", got, want)606+ }607+}608+609+func TestSolvedBridgeVisualStaysLegibleOnBoardBackground(t *testing.T) {580610 p := Puzzle{581611 Width: 3,582612 Height: 1,
@@ -588,20 +618,29 @@ func TestSolvedBridgeVisualStaysLegibleOverBlueBoard(t *testing.T) {
588618 p.SetBridge(0, 1, 1)589619 m := Model{puzzle: p, cursorIsland: 0, keys: DefaultKeyMap}590620 591- empty := resolveCellVisual(m, 0, 0, true)592- if !sameColor(empty.outerBG, boardBackground()) {593- t.Fatal("expected solved island outer background to stay on the board color")621+ island := resolveCellVisual(m, 0, 0, true)622+ if !sameColor(island.outerBG, solvedBoardBackground()) {623+ t.Fatal("expected solved island outer background to use the solved board color")594624 }595625 596626 bridge := resolveCellVisual(m, 1, 0, true)597- if !sameColor(bridge.bg, boardBackground()) {598- t.Fatal("expected solved bridge cell to keep the blue board background")627+ if !sameColor(bridge.bg, solvedBoardBackground()) {628+ t.Fatal("expected solved bridge cell to use the solved board color")599629 }600630 if !sameColor(bridge.fg, bridgeColor(0)) {601631 t.Fatal("expected solved bridge cell to use the bridge palette color")602632 }603633 }604634 635+func TestResolveCellVisualSolvedEmptyCellUsesSolvedBoardBackground(t *testing.T) {636+ m := Model{puzzle: *linePuzzle(), cursorIsland: 0, keys: DefaultKeyMap}637+638+ empty := resolveCellVisual(m, 1, 0, true)639+ if !sameColor(empty.bg, solvedBoardBackground()) {640+ t.Fatal("expected solved empty cell to use the solved board color")641+ }642+}643+605644 func TestBridgeForegroundUsesThemePalettePerBridge(t *testing.T) {606645 p := Puzzle{607646 Width: 5,
@@ -649,7 +688,11 @@ func sameColor(left, right color.Color) bool {
649688 }650689 651690 func renderExpectedIslandCell(text string, fg, bg color.Color, bold bool) string {652- side := lipgloss.NewStyle().Width((cellWidth - islandPillWidth) / 2).Background(boardBackground()).Render("")691+ return renderExpectedIslandCellWithOuterBG(text, fg, bg, boardBackground(), bold)692+}693+694+func renderExpectedIslandCellWithOuterBG(text string, fg, bg, outerBG color.Color, bold bool) string {695+ side := lipgloss.NewStyle().Width((cellWidth - islandPillWidth) / 2).Background(outerBG).Render("")653696 center := lipgloss.NewStyle().654697 Width(islandPillWidth).655698 AlignHorizontal(lipgloss.Center).
M hashiwokakero/style.go +15 -10
@@ -37,7 +37,12 @@ func adjacentIslandBackground() color.Color {
3737 }3838 3939 func solvedIslandBackground() color.Color {40- return theme.Blend(baseIslandBackground(), theme.Current().Success, 0.18)40+ p := theme.Current()41+ return theme.Blend(p.BG, p.SuccessBorder, 0.55)42+}43+44+func solvedBoardBackground() color.Color {45+ return theme.Current().SuccessBG4146 }4247 4348 func baseCellStyle(fg, bg color.Color, bold bool) lipgloss.Style {
@@ -85,11 +90,15 @@ func isHighlightedNeighbor(m Model, islandID int) bool {
8590 8691 func resolveCellVisual(m Model, x, y int, solved bool) cellVisual {8792 boardBG := boardBackground()93+ cellBG := boardBG94+ if solved {95+ cellBG = solvedBoardBackground()96+ }8897 visual := cellVisual{8998 text: " ",90- fg: theme.TextOnBG(boardBG),91- bg: boardBG,92- outerBG: boardBG,99+ fg: theme.TextOnBG(cellBG),100+ bg: cellBG,101+ outerBG: cellBG,93102 }94103 95104 ci := m.puzzle.CellContent(x, y)
@@ -105,16 +114,12 @@ func resolveCellVisual(m Model, x, y int, solved bool) cellVisual {
105114 current := m.puzzle.BridgeCount(ci.IslandID)106115 islandBG := baseIslandBackground()107116 visual.pill = true108- visual.outerBG = boardBG117+ visual.outerBG = cellBG109118 110119 switch {111- case solved && isCursor:112- visual.fg = game.CursorFG()113- visual.bg = theme.Current().SuccessBG114- visual.bold = true115120 case solved:116121 islandBG = solvedIslandBackground()117- visual.fg = theme.TextOnBG(islandBG)122+ visual.fg = theme.Current().SolvedFG118123 visual.bg = islandBG119124 visual.bold = true120125 case m.selectedIsland != nil && *m.selectedIsland == ci.IslandID:
M hitori/Gamemode.go +1 -1
M hitori/Model.go +23 -0
@@ -23,6 +23,11 @@ type Model struct {
2323 keys KeyMap2424 modeTitle string2525 showFullHelp bool26+ termWidth int27+ termHeight int28+ originX int29+ originY int30+ originValid bool2631 }2732 2833 var _ game.Gamer = Model{}
@@ -63,6 +68,18 @@ func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {
6368 switch msg := msg.(type) {6469 case game.HelpToggleMsg:6570 m.showFullHelp = msg.Show71+ m.originValid = false72+ case tea.WindowSizeMsg:73+ m.termWidth = msg.Width74+ m.termHeight = msg.Height75+ m.originValid = false76+ case tea.MouseClickMsg:77+ if msg.Button == tea.MouseLeft {78+ if col, row, ok := m.screenToGrid(msg.X, msg.Y); ok {79+ m.cursor.X = col80+ m.cursor.Y = row81+ }82+ }6683 case tea.KeyPressMsg:6784 switch {6885 case key.Matches(msg, m.keys.ShadeCell):
@@ -98,9 +115,13 @@ func (m *Model) toggleMark(mark cellMark) {
98115 }99116 100117 func (m *Model) recomputeState() {118+ wasSolved := m.solved101119 m.marks = propagateRequiredMarks(m.numbers, m.userMarks, m.size)102120 m.solved = m.checkSolved()103121 m.conflicts = computeConflicts(m.numbers, m.marks, m.size)122+ if m.solved != wasSolved {123+ m.originValid = false124+ }104125 }105126 106127 func (m Model) View() string {
@@ -116,6 +137,7 @@ func (m Model) View() string {
116137 117138 func (m Model) SetTitle(t string) game.Gamer {118139 m.modeTitle = t140+ m.originValid = false119141 return m120142 }121143
@@ -127,6 +149,7 @@ func (m Model) Reset() game.Gamer {
127149 m.userMarks = cloneMarks(m.initialUserMarks)128150 m.recomputeState()129151 m.cursor = game.Cursor{}152+ m.originValid = false130153 return m131154 }132155
M hitori/PrintAdapter.go +1 -2
M hitori/hitori_test.go +205 -0
@@ -2,12 +2,16 @@ package hitori
22 33 import (44 "encoding/json"5+ "image"6+ "image/color"57 "math/rand/v2"68 "strings"79 "testing"810 911 tea "charm.land/bubbletea/v2"1012 "github.com/FelineStateMachine/puzzletea/game"13+ "github.com/FelineStateMachine/puzzletea/theme"14+ "github.com/charmbracelet/x/ansi"1115 )1216 1317 // --- Helpers ---
@@ -42,6 +46,15 @@ func testMode(size int) HitoriMode {
4246 return NewMode("Test", "test mode", size, 0.25)4347 }4448 49+func sameColor(left, right color.Color) bool {50+ if left == nil || right == nil {51+ return left == nil && right == nil52+ }53+ lr, lg, lb, la := left.RGBA()54+ rr, rg, rb, ra := right.RGBA()55+ return lr == rr && lg == rg && lb == rb && la == ra56+}57+4558 // --- Grid and serialization (P0) ---4659 4760 func TestNewGrid(t *testing.T) {
@@ -150,6 +163,122 @@ func TestCellView_CursorSolvedShadedPreservesShadedPayload(t *testing.T) {
150163 }151164 }152165 166+func TestGridViewUsesDynamicGridWithoutInteriorSeparators(t *testing.T) {167+ numbers := makeGrid("123", "456", "789")168+ marks := makeMarks("...", "...", "...")169+ conflicts := computeConflicts(numbers, marks, 3)170+171+ lines := strings.Split(ansi.Strip(gridView(numbers, marks, game.Cursor{X: 1, Y: 1}, false, conflicts)), "\n")172+ if got, want := len(lines), 7; got != want {173+ t.Fatalf("line count = %d, want %d", got, want)174+ }175+ if !strings.HasPrefix(lines[0], "┌") || !strings.HasSuffix(lines[0], "┐") {176+ t.Fatalf("top row = %q, want outer border corners", lines[0])177+ }178+ last := lines[len(lines)-1]179+ if !strings.HasPrefix(last, "└") || !strings.HasSuffix(last, "┘") {180+ t.Fatalf("bottom row = %q, want outer border corners", last)181+ }182+183+ for _, idx := range []int{1, 2, 3, 4, 5} {184+ line := lines[idx]185+ if strings.Count(line, "│") != 2 {186+ t.Fatalf("line %d = %q, want only outer vertical borders", idx, line)187+ }188+ }189+ for _, idx := range []int{2, 4} {190+ if strings.Contains(lines[idx], "─") {191+ t.Fatalf("line %d = %q, want no interior horizontal separators", idx, lines[idx])192+ }193+ }194+}195+196+func TestBridgeFillUsesVerticalCrosshairAcrossOpenInterior(t *testing.T) {197+ numbers := makeGrid("12", "34")198+ marks := makeMarks("..", "..")199+ conflicts := computeConflicts(numbers, marks, 2)200+201+ got := bridgeFill(numbers, marks, game.Cursor{X: 0, Y: 1}, false, conflicts, game.DynamicGridBridge{202+ Kind: game.DynamicGridBridgeVertical,203+ X: 1,204+ Y: 1,205+ Count: 2,206+ Uniform: true,207+ Cells: [4]image.Point{208+ {X: 0, Y: 1},209+ {X: 1, Y: 1},210+ },211+ })212+ if !sameColor(got, theme.Current().Surface) {213+ t.Fatal("expected open vertical bridge on cursor row to use crosshair background")214+ }215+}216+217+func TestBridgeFillUsesHorizontalCrosshairAcrossOpenInterior(t *testing.T) {218+ numbers := makeGrid("12", "34")219+ marks := makeMarks("..", "..")220+ conflicts := computeConflicts(numbers, marks, 2)221+222+ got := bridgeFill(numbers, marks, game.Cursor{X: 0, Y: 1}, false, conflicts, game.DynamicGridBridge{223+ Kind: game.DynamicGridBridgeHorizontal,224+ X: 0,225+ Y: 1,226+ Count: 2,227+ Uniform: true,228+ Cells: [4]image.Point{229+ {X: 0, Y: 0},230+ {X: 0, Y: 1},231+ },232+ })233+ if !sameColor(got, theme.Current().Surface) {234+ t.Fatal("expected open horizontal bridge on cursor column to use crosshair background")235+ }236+}237+238+func TestBridgeFillLeavesInteriorJunctionOpen(t *testing.T) {239+ numbers := makeGrid("12", "34")240+ marks := makeMarks("..", "..")241+ conflicts := computeConflicts(numbers, marks, 2)242+243+ got := bridgeFill(numbers, marks, game.Cursor{X: 0, Y: 1}, false, conflicts, game.DynamicGridBridge{244+ Kind: game.DynamicGridBridgeJunction,245+ X: 1,246+ Y: 1,247+ Count: 4,248+ Uniform: true,249+ Cells: [4]image.Point{250+ {X: 0, Y: 0},251+ {X: 1, Y: 0},252+ {X: 0, Y: 1},253+ {X: 1, Y: 1},254+ },255+ })256+ if got != nil {257+ t.Fatal("expected open interior junction to remain unfilled")258+ }259+}260+261+func TestBridgeFillReturnsNilWhenSolved(t *testing.T) {262+ numbers := makeGrid("12", "34")263+ marks := makeMarks("..", "..")264+ conflicts := computeConflicts(numbers, marks, 2)265+266+ got := bridgeFill(numbers, marks, game.Cursor{X: 0, Y: 0}, true, conflicts, game.DynamicGridBridge{267+ Kind: game.DynamicGridBridgeVertical,268+ X: 1,269+ Y: 0,270+ Count: 2,271+ Uniform: true,272+ Cells: [4]image.Point{273+ {X: 0, Y: 0},274+ {X: 1, Y: 0},275+ },276+ })277+ if got != nil {278+ t.Fatal("expected solved bridges to use dynamic grid default solved styling")279+ }280+}281+153282 // --- Propagation: required moves (P0) ---154283 155284 func TestPropagateRequiredMarks_ShadedCellForcesOrthogonalWhite(t *testing.T) {
@@ -952,6 +1081,77 @@ func TestModel_View(t *testing.T) {
9521081 }9531082 }9541083 1084+func TestScreenToGridUsesDynamicGridCoordinates(t *testing.T) {1085+ mode := testMode(3)1086+ numbers := makeGrid("123", "456", "789")1087+ g, _ := New(mode, numbers)1088+ next, _ := g.Update(tea.WindowSizeMsg{Width: 120, Height: 40})1089+ m := next.(Model)1090+ x, y := m.cachedGridOrigin()1091+1092+ if col, row, ok := m.screenToGrid(x, y); !ok || col != 0 || row != 0 {1093+ t.Fatalf("screenToGrid(origin) = (%d,%d,%v), want (0,0,true)", col, row, ok)1094+ }1095+ if _, _, ok := m.screenToGrid(x+cellWidth, y); ok {1096+ t.Fatal("screenToGrid(separator) = ok, want false")1097+ }1098+}1099+1100+func TestMouseClickMovesCursor(t *testing.T) {1101+ mode := testMode(3)1102+ numbers := makeGrid("123", "456", "789")1103+ g, _ := New(mode, numbers)1104+ next, _ := g.Update(tea.WindowSizeMsg{Width: 120, Height: 40})1105+ m := next.(Model)1106+ x, y := hitoriCellScreenCoords(&m, 2, 1)1107+1108+ next, _ = m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})1109+ got := next.(Model)1110+1111+ if got.cursor.X != 2 || got.cursor.Y != 1 {1112+ t.Fatalf("cursor = (%d,%d), want (2,1)", got.cursor.X, got.cursor.Y)1113+ }1114+ if got.userMarks[1][2] != unmarked {1115+ t.Fatalf("clicked destination mark = %v, want unchanged unmarked", got.userMarks[1][2])1116+ }1117+}1118+1119+func TestMouseClickOnSeparatorDoesNotMoveCursor(t *testing.T) {1120+ mode := testMode(3)1121+ numbers := makeGrid("123", "456", "789")1122+ g, _ := New(mode, numbers)1123+ next, _ := g.Update(tea.WindowSizeMsg{Width: 120, Height: 40})1124+ m := next.(Model)1125+ m.cursor = game.Cursor{X: 1, Y: 1}1126+ x, y := hitoriCellScreenCoords(&m, 1, 1)1127+1128+ next, _ = m.Update(tea.MouseClickMsg{X: x + cellWidth, Y: y, Button: tea.MouseLeft})1129+ got := next.(Model)1130+1131+ if got.cursor.X != 1 || got.cursor.Y != 1 {1132+ t.Fatalf("cursor = (%d,%d), want unchanged (1,1)", got.cursor.X, got.cursor.Y)1133+ }1134+}1135+1136+func TestMouseClickSameCellDoesNotMutateMarks(t *testing.T) {1137+ mode := testMode(3)1138+ numbers := makeGrid("123", "456", "789")1139+ g, _ := New(mode, numbers)1140+ next, _ := g.Update(tea.WindowSizeMsg{Width: 120, Height: 40})1141+ m := next.(Model)1142+ m.cursor = game.Cursor{X: 1, Y: 1}1143+ m.userMarks[1][1] = shaded1144+ m.recomputeState()1145+ x, y := hitoriCellScreenCoords(&m, 1, 1)1146+1147+ next, _ = m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})1148+ got := next.(Model)1149+1150+ if got.userMarks[1][1] != shaded {1151+ t.Fatalf("mark = %v, want unchanged shaded", got.userMarks[1][1])1152+ }1153+}1154+9551155 // --- Gamer interface compliance (P0) ---9561156 9571157 func TestGamerInterface(t *testing.T) {
@@ -975,6 +1175,11 @@ func TestModeTitleDescription(t *testing.T) {
9751175 }9761176 }9771177 1178+func hitoriCellScreenCoords(m *Model, col, row int) (x, y int) {1179+ ox, oy := m.cachedGridOrigin()1180+ return ox + col*(cellWidth+1), oy + row*21181+}1182+9781183 // --- Puzzle generation (P2) ---9791184 9801185 func TestGenerate_SmallPuzzle(t *testing.T) {
A hitori/mouse.go +36 -0
M hitori/style.go +147 -78
@@ -2,131 +2,200 @@ package hitori
22 33 import (44 "fmt"5+ "image/color"56 67 "charm.land/lipgloss/v2"78 "github.com/FelineStateMachine/puzzletea/game"89 "github.com/FelineStateMachine/puzzletea/theme"910 )1011 11-const cellWidth = 312+const cellWidth = game.DynamicGridCellWidth1213 1314 const shadedCellDisplay = " █ "1415 16+type cellVisualState int17+18+const (19+ cellVisualStateBase cellVisualState = iota20+ cellVisualStateCrosshair21+ cellVisualStateCursor22+ cellVisualStateSolved23+ cellVisualStateConflict24+)25+26+type cellVisual struct {27+ text string28+ fg color.Color29+ bg color.Color30+ bridgeBG color.Color31+ bold bool32+ state cellVisualState33+}34+1535 func cellView(num rune, mark cellMark, isCursor, inCursorRow, inCursorCol, solved, conflict bool) string {36+ return renderCellVisual(resolveCellVisual(num, mark, isCursor, inCursorRow, inCursorCol, solved, conflict))37+}38+39+func resolveCellVisual(40+ num rune,41+ mark cellMark,42+ isCursor, inCursorRow, inCursorCol, solved, conflict bool,43+) cellVisual {1644 p := theme.Current()17- s, display := hitoriCellBase(mark, num)45+ visual := hitoriBaseVisual(mark, num)1846 1947 // Priority: cursor+solved > cursor+conflict > cursor > solved > conflict > crosshair > base.20- if isCursor {21- s = hitoriCursorStyle(mark, solved, conflict)22- } else if solved {23- s = s.Foreground(p.SolvedFG).Background(p.SuccessBG)24- } else if conflict {25- s = s.Foreground(game.ConflictFG()).Background(game.ConflictBG())26- } else if inCursorRow || inCursorCol {27- s = s.Background(p.Surface)48+ switch {49+ case isCursor:50+ return hitoriCursorVisual(num, mark, solved, conflict)51+ case solved:52+ visual.fg = p.SolvedFG53+ visual.bg = p.SuccessBG54+ visual.bridgeBG = p.SuccessBG55+ visual.state = cellVisualStateSolved56+ case conflict:57+ visual.fg = game.ConflictFG()58+ visual.bg = game.ConflictBG()59+ visual.bridgeBG = game.ConflictBG()60+ visual.state = cellVisualStateConflict61+ case inCursorRow || inCursorCol:62+ visual.bg = p.Surface63+ visual.bridgeBG = p.Surface64+ visual.state = cellVisualStateCrosshair2865 }2966 30- return s.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(display)67+ return visual3168 }3269 33-func hitoriCellBase(mark cellMark, num rune) (lipgloss.Style, string) {70+func hitoriBaseVisual(mark cellMark, num rune) cellVisual {3471 p := theme.Current()3572 3673 switch mark {3774 case shaded:38- return lipgloss.NewStyle().39- Foreground(p.Surface).40- Background(p.Surface),41- shadedCellDisplay75+ return cellVisual{76+ text: shadedCellDisplay,77+ fg: p.Surface,78+ bg: p.Surface,79+ bridgeBG: p.Surface,80+ state: cellVisualStateBase,81+ }4282 case circled:43- return lipgloss.NewStyle().44- Foreground(p.Info).45- Background(p.BG),46- fmt.Sprintf(" %c ", num)83+ return cellVisual{84+ text: fmt.Sprintf(" %c ", num),85+ fg: p.Info,86+ bg: p.BG,87+ bridgeBG: p.BG,88+ state: cellVisualStateBase,89+ }4790 default:48- return lipgloss.NewStyle().49- Foreground(p.FG).50- Background(p.BG),51- fmt.Sprintf(" %c ", num)91+ return cellVisual{92+ text: fmt.Sprintf(" %c ", num),93+ fg: p.FG,94+ bg: p.BG,95+ bridgeBG: p.BG,96+ state: cellVisualStateBase,97+ }5298 }5399 }54100 55-func hitoriCursorStyle(mark cellMark, solved, conflict bool) lipgloss.Style {101+func hitoriCursorVisual(num rune, mark cellMark, solved, conflict bool) cellVisual {56102 p := theme.Current()57- style := lipgloss.NewStyle().Bold(true)103+ visual := hitoriBaseVisual(mark, num)104+ visual.bold = true105+106+ switch mark {107+ case circled:108+ visual.fg = p.Info109+ case shaded:110+ visual.fg = game.CursorFG()111+ default:112+ visual.fg = game.CursorFG()113+ }58114 59115 switch {60116 case solved:61- style = style.Background(p.SuccessBG)62- switch mark {63- case circled:64- style = style.Foreground(p.Info)65- case shaded:66- style = style.Foreground(p.SolvedFG)67- default:68- style = style.Foreground(game.CursorFG())117+ visual.bg = p.SuccessBG118+ visual.bridgeBG = p.SuccessBG119+ visual.state = cellVisualStateSolved120+ if mark == shaded {121+ visual.fg = p.SolvedFG69122 }70123 case conflict:71- style = style.Background(game.ConflictBG()).Underline(true)72- switch mark {73- case circled:74- style = style.Foreground(p.Info)75- case shaded:76- style = style.Foreground(game.CursorFG())77- default:78- style = style.Foreground(game.CursorFG())79- }124+ visual.bg = game.ConflictBG()125+ visual.bridgeBG = game.ConflictBG()126+ visual.state = cellVisualStateConflict80127 default:81- style = style.Background(game.CursorBG())82- switch mark {83- case circled:84- style = style.Foreground(p.Info)85- case shaded:86- style = style.Foreground(game.CursorFG())87- default:88- style = style.Foreground(game.CursorFG())89- }128+ visual.bg = game.CursorBG()129+ visual.bridgeBG = p.Surface130+ visual.state = cellVisualStateCursor90131 }91132 92- return style133+ return visual93134 }94135 95-func gridView(numbers grid, marks [][]cellMark, c game.Cursor, solved bool, conflicts [][]bool) string {96- colors := game.DefaultBorderColors()97-98- w := 099- if len(numbers) > 0 {100- w = len(numbers[0])136+func renderCellVisual(visual cellVisual) string {137+ style := lipgloss.NewStyle().138+ Width(cellWidth).139+ AlignHorizontal(lipgloss.Center).140+ Foreground(visual.fg).141+ Background(visual.bg)142+ if visual.bold {143+ style = style.Bold(true)101144 }145+ return style.Render(visual.text)146+}102147 103- var rows []string104- for y, row := range numbers {105- var rowCells []string106- inCursorRow := y == c.Y148+func bridgeFill(numbers grid, marks [][]cellMark, c game.Cursor, solved bool, conflicts [][]bool, bridge game.DynamicGridBridge) color.Color {149+ _, _, _ = numbers, marks, conflicts150+ if solved {151+ return nil152+ }107153 108- rowCells = append(rowCells, game.BorderChar("\u2502", colors, solved, !solved && inCursorRow))154+ if game.DynamicGridBridgeOnCrosshairAxis(c, bridge) {155+ return theme.Current().Surface156+ }109157 110- for x, num := range row {111- isCursor := x == c.X && y == c.Y112- inCursorCol := x == c.X113- cell := cellView(num, marks[y][x], isCursor, inCursorRow, inCursorCol, solved, conflicts[y][x])114- rowCells = append(rowCells, cell)115- }158+ return nil159+}116160 117- rowCells = append(rowCells, game.BorderChar("\u2502", colors, solved, !solved && inCursorRow))118- rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, rowCells...))161+func gridView(numbers grid, marks [][]cellMark, c game.Cursor, solved bool, conflicts [][]bool) string {162+ height := len(numbers)163+ width := 0164+ if height > 0 {165+ width = len(numbers[0])119166 }120167 121- topRow := game.HBorderRow(w, c.X, cellWidth, "\u256d", "\u256e", colors, solved)122- botRow := game.HBorderRow(w, c.X, cellWidth, "\u2570", "\u256f", colors, solved)123-124- return lipgloss.JoinVertical(lipgloss.Left, topRow, lipgloss.JoinVertical(lipgloss.Left, rows...), botRow)168+ return game.RenderDynamicGrid(game.DynamicGridSpec{169+ Width: width,170+ Height: height,171+ CellWidth: cellWidth,172+ Solved: solved,173+ Cell: func(x, y int) string {174+ return renderCellVisual(resolveCellVisual(175+ numbers[y][x],176+ marks[y][x],177+ x == c.X && y == c.Y,178+ y == c.Y,179+ x == c.X,180+ solved,181+ conflicts[y][x],182+ ))183+ },184+ HasVerticalEdge: func(x, _ int) bool {185+ return x <= 0 || x >= width186+ },187+ HasHorizontalEdge: func(_, y int) bool {188+ return y <= 0 || y >= height189+ },190+ BridgeFill: func(bridge game.DynamicGridBridge) color.Color {191+ return bridgeFill(numbers, marks, c, solved, conflicts, bridge)192+ },193+ })125194 }126195 127196 func statusBarView(showFullHelp bool) string {128197 if showFullHelp {129- return game.StatusBarStyle().Render("arrows/wasd: move x: shade z: circle bkspc: clear esc: menu ctrl+r: reset ctrl+h: help")198+ return game.StatusBarStyle().Render("arrows/wasd: move mouse: click move x: shade z: circle bkspc: clear esc: menu ctrl+r: reset ctrl+h: help")130199 }131- return game.StatusBarStyle().Render("x: shade z: circle bkspc: clear")200+ return game.StatusBarStyle().Render("mouse: click move x: shade z: circle bkspc: clear")132201 }
M lightsout/Gamemode.go +1 -1
M nonogram/Gamemode.go +1 -1
M nonogram/PrintAdapter.go +1 -2
M nurikabe/Gamemode.go +1 -1
M nurikabe/Model.go +21 -12
M nurikabe/PrintAdapter.go +1 -2
M nurikabe/README.md +4 -1
M nurikabe/help.md +4 -2
M nurikabe/nurikabe_test.go +327 -30
@@ -171,6 +171,21 @@ func TestIsSolvedGrid(t *testing.T) {
171171 }172172 }173173 174+func TestIsSolvedGridAllowsImplicitIslandCells(t *testing.T) {175+ clues := makeClues(176+ []int{2, 0, 1, 0},177+ []int{0, 0, 0, 0},178+ )179+ marks := grid{180+ []cellState{islandCell, seaCell, islandCell, seaCell},181+ []cellState{unknownCell, seaCell, seaCell, seaCell},182+ }183+184+ if !isSolvedGrid(marks, clues) {185+ t.Fatal("expected unknown non-sea cells to count as island for solved check")186+ }187+}188+174189 // --- Solver counts (P0) ---175190 176191 func TestCountSolutions(t *testing.T) {
@@ -329,6 +344,9 @@ func TestModelMouseDragPaint(t *testing.T) {
329344 clickX, clickY := cellPoint(ox, oy, 1, 1)330345 dragX, dragY := separatorPoint(ox, oy, 2, 1)331346 347+ next, _ = m.Update(tea.MouseClickMsg{X: clickX, Y: clickY, Button: tea.MouseLeft})348+ m = next.(Model)349+332350 click := tea.MouseClickMsg{X: clickX, Y: clickY, Button: tea.MouseLeft}333351 next, _ = m.Update(click)334352 m = next.(Model)
@@ -345,6 +363,38 @@ func TestModelMouseDragPaint(t *testing.T) {
345363 }346364 }347365 366+func TestModelMouseClickMovesCursorWithoutPainting(t *testing.T) {367+ mode := NewMode("Mini", "test", 3, 3, 0.3, 5)368+ p := Puzzle{Width: 3, Height: 3, Clues: makeClues(369+ []int{2, 0, 0},370+ []int{0, 0, 0},371+ []int{0, 0, 1},372+ )}373+ g, err := New(mode, p)374+ if err != nil {375+ t.Fatalf("New error: %v", err)376+ }377+ m := g.(Model)378+379+ next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})380+ m = next.(Model)381+ ox, oy := m.gridOrigin()382+ cellX, cellY := cellPoint(ox, oy, 2, 1)383+384+ next, _ = m.Update(tea.MouseClickMsg{X: cellX, Y: cellY, Button: tea.MouseLeft})385+ m = next.(Model)386+387+ if m.cursor.X != 2 || m.cursor.Y != 1 {388+ t.Fatalf("cursor = (%d,%d), want (2,1)", m.cursor.X, m.cursor.Y)389+ }390+ if m.marks[1][2] != unknownCell {391+ t.Fatalf("mouse click should not paint destination cell, got %q", rune(m.marks[1][2]))392+ }393+ if m.dragging {394+ t.Fatal("mouse click on a new cell should not start dragging")395+ }396+}397+348398 func TestModelMouseClickMirrorsKeyboard(t *testing.T) {349399 mode := NewMode("Mini", "test", 3, 3, 0.3, 5)350400 p := Puzzle{Width: 3, Height: 3, Clues: makeClues(
@@ -366,14 +416,17 @@ func TestModelMouseClickMirrorsKeyboard(t *testing.T) {
366416 367417 next, _ = m.Update(tea.MouseClickMsg{X: cellX, Y: cellY, Button: tea.MouseLeft})368418 m = next.(Model)369- if m.marks[1][1] != seaCell {370- t.Fatalf("left click from unknown should set sea, got %q", rune(m.marks[1][1]))419+ if m.cursor.X != 1 || m.cursor.Y != 1 {420+ t.Fatalf("cursor = (%d,%d), want (1,1)", m.cursor.X, m.cursor.Y)421+ }422+ if m.marks[1][1] != unknownCell {423+ t.Fatalf("left click on a new cell should only move cursor, got %q", rune(m.marks[1][1]))371424 }372425 373426 next, _ = m.Update(tea.MouseClickMsg{X: cellX, Y: cellY, Button: tea.MouseLeft})374427 m = next.(Model)375428 if m.marks[1][1] != seaCell {376- t.Fatalf("left click from sea should keep sea, got %q", rune(m.marks[1][1]))429+ t.Fatalf("left click on the current cell should set sea, got %q", rune(m.marks[1][1]))377430 }378431 379432 next, _ = m.Update(tea.MouseClickMsg{X: cellX, Y: cellY, Button: tea.MouseRight})
@@ -674,44 +727,79 @@ func TestScreenToGridDragSnapsAcrossSeparator(t *testing.T) {
674727 }675728 }676729 677-func TestGridViewUsesDynamicGridWithoutInteriorSeparators(t *testing.T) {678- mode := NewMode("Mini", "test", 3, 3, 0.3, 5)679- p := Puzzle{Width: 3, Height: 3, Clues: makeClues(680- []int{2, 0, 0},681- []int{0, 0, 0},682- []int{0, 0, 1},730+func TestBuildRenderGridStateAssignsIslandZonesAndColors(t *testing.T) {731+ mode := NewMode("Mini", "test", 4, 2, 0.3, 5)732+ p := Puzzle{Width: 4, Height: 2, Clues: makeClues(733+ []int{2, 0, 1, 0},734+ []int{0, 0, 0, 0},683735 )}684736 g, err := New(mode, p)685737 if err != nil {686738 t.Fatalf("New error: %v", err)687739 }688740 m := g.(Model)689- m.cursor = pointToCursor(1, 1)741+ m.cursor = pointToCursor(0, 1)742+ m.setCellAtCursor(islandCell)743+ m.cursor = pointToCursor(3, 1)744+ m.setCellAtCursor(islandCell)745+ m.cursor = pointToCursor(1, 0)690746 m.setCellAtCursor(seaCell)691- m.cursor = pointToCursor(2, 1)747+748+ renderState := buildRenderGridState(m)749+ themeColors := theme.Current().ThemeColors()750+751+ if got := renderState.zones[0][0]; got != renderState.zones[1][0] {752+ t.Fatalf("connected island cells should share a zone: got %d and %d", renderState.zones[0][0], renderState.zones[1][0])753+ }754+ if got := renderState.zones[0][0]; got == renderState.zones[0][2] {755+ t.Fatalf("separate clue islands should not share a zone: got %d", got)756+ }757+ if got := renderState.zones[0][2]; got == renderState.zones[1][3] {758+ t.Fatalf("orphan island component should get its own zone: got %d", got)759+ }760+ if got := renderState.zones[0][1]; got != neutralZone {761+ t.Fatalf("sea cell zone = %d, want %d", got, neutralZone)762+ }763+ if got := renderState.zones[1][1]; got != neutralZone {764+ t.Fatalf("unknown cell zone = %d, want %d", got, neutralZone)765+ }766+767+ for _, zone := range []int{768+ renderState.zones[0][0],769+ renderState.zones[0][2],770+ renderState.zones[1][3],771+ } {772+ want := themeColors[zone%len(themeColors)]773+ if !sameColor(renderState.zoneColors[zone], want) {774+ t.Fatalf("zone %d color mismatch", zone)775+ }776+ }777+}778+779+func TestGridViewUsesIslandZonesForInteriorBoundaries(t *testing.T) {780+ mode := NewMode("Mini", "test", 3, 1, 0.3, 5)781+ p := Puzzle{Width: 3, Height: 1, Clues: makeClues(782+ []int{2, 0, 0},783+ )}784+ g, err := New(mode, p)785+ if err != nil {786+ t.Fatalf("New error: %v", err)787+ }788+ m := g.(Model)789+ m.cursor = pointToCursor(1, 0)692790 m.setCellAtCursor(islandCell)693791 694792 lines := strings.Split(ansi.Strip(gridView(m)), "\n")695793 if got, want := len(lines), m.height*2+1; got != want {696794 t.Fatalf("line count = %d, want %d", got, want)697795 }698- if !strings.HasPrefix(lines[0], "┌") || !strings.HasSuffix(lines[0], "┐") {699- t.Fatal("expected top border")700- }701- last := lines[len(lines)-1]702- if !strings.HasPrefix(last, "└") || !strings.HasSuffix(last, "┘") {703- t.Fatal("expected bottom border")796+797+ content := []rune(lines[1])798+ if got := content[4]; got != ' ' {799+ t.Fatalf("connected island bridge = %q, want space", got)704800 }705- for _, idx := range []int{1, 2, 3, 4, 5} {706- line := lines[idx]707- if strings.Count(line, "│") != 2 {708- t.Fatalf("line %d = %q, want only outer vertical borders", idx, line)709- }710- }711- for _, idx := range []int{2, 4} {712- if strings.Contains(lines[idx], "─") {713- t.Fatalf("line %d = %q, want no interior horizontal separators", idx, lines[idx])714- }801+ if got := content[8]; got != '│' {802+ t.Fatalf("island boundary bridge = %q, want vertical border", got)715803 }716804 }717805
@@ -742,10 +830,11 @@ func TestBridgeFillBlendsMixedTerrainBackgrounds(t *testing.T) {
742830 })743831 744832 palette := theme.Current()833+ renderState := buildRenderGridState(m)745834 want := color.NRGBA{}746- land := theme.Blend(palette.BG, palette.Success, 0.45)835+ island := zoneBackground(renderState, renderState.zones[0][0])747836 sea := theme.Blend(palette.BG, palette.Secondary, 0.24)748- lr, lg, lb, la := land.RGBA()837+ lr, lg, lb, la := island.RGBA()749838 sr, sg, sb, sa := sea.RGBA()750839 want.R = uint8(((lr >> 8) + (sr >> 8)) / 2)751840 want.G = uint8(((lg >> 8) + (sg >> 8)) / 2)
@@ -753,7 +842,39 @@ func TestBridgeFillBlendsMixedTerrainBackgrounds(t *testing.T) {
753842 want.A = uint8(((la >> 8) + (sa >> 8)) / 2)754843 755844 if !sameColor(got, want) {756- t.Fatal("expected mixed land/sea bridge to use blended background")845+ t.Fatal("expected mixed island/sea bridge to use blended background")846+ }847+}848+849+func TestBridgeFillDefersToZoneFillForUniformIslandBridge(t *testing.T) {850+ mode := NewMode("Mini", "test", 2, 1, 0.3, 5)851+ p := Puzzle{Width: 2, Height: 1, Clues: makeClues(852+ []int{2, 0},853+ )}854+ g, err := New(mode, p)855+ if err != nil {856+ t.Fatalf("New error: %v", err)857+ }858+ m := g.(Model)859+ m.cursor = pointToCursor(1, 0)860+ m.setCellAtCursor(islandCell)861+ renderState := buildRenderGridState(m)862+863+ got := bridgeFillWithState(m, renderState, game.DynamicGridBridge{864+ Kind: game.DynamicGridBridgeVertical,865+ X: 1,866+ Y: 0,867+ Count: 2,868+ Uniform: true,869+ Zone: renderState.zones[0][0],870+ Cells: [4]image.Point{871+ {X: 0, Y: 0},872+ {X: 1, Y: 0},873+ },874+ })875+876+ if got != nil {877+ t.Fatal("expected uniform island bridge to defer to zone fill")757878 }758879 }759880
@@ -786,6 +907,182 @@ func TestBridgeFillDoesNotExpandCursorBackground(t *testing.T) {
786907 }787908 }788909 910+func TestResolveCellVisualShowsImplicitIslandWhenSolved(t *testing.T) {911+ mode := NewMode("Mini", "test", 4, 2, 0.3, 5)912+ p := Puzzle{Width: 4, Height: 2, Clues: makeClues(913+ []int{2, 0, 1, 0},914+ []int{0, 0, 0, 0},915+ )}916+ g, err := New(mode, p)917+ if err != nil {918+ t.Fatalf("New error: %v", err)919+ }920+ m := g.(Model)921+ m.cursor = pointToCursor(1, 0)922+ m.setCellAtCursor(seaCell)923+ m.cursor = pointToCursor(1, 1)924+ m.setCellAtCursor(seaCell)925+ m.cursor = pointToCursor(2, 1)926+ m.setCellAtCursor(seaCell)927+ m.cursor = pointToCursor(3, 0)928+ m.setCellAtCursor(seaCell)929+ m.cursor = pointToCursor(3, 1)930+ m.setCellAtCursor(seaCell)931+932+ if !m.solved {933+ t.Fatal("expected puzzle to be solved")934+ }935+936+ visual := resolveCellVisual(m, 0, 1)937+ if visual.text != " · " {938+ t.Fatalf("implicit solved island text = %q, want %q", visual.text, " · ")939+ }940+}941+942+func TestResolveCellVisualUsesIslandZoneColor(t *testing.T) {943+ mode := NewMode("Mini", "test", 3, 2, 0.3, 5)944+ p := Puzzle{Width: 3, Height: 2, Clues: makeClues(945+ []int{2, 0, 1},946+ []int{0, 0, 0},947+ )}948+ g, err := New(mode, p)949+ if err != nil {950+ t.Fatalf("New error: %v", err)951+ }952+ m := g.(Model)953+ m.cursor = pointToCursor(0, 1)954+ m.setCellAtCursor(islandCell)955+ m.cursor = pointToCursor(1, 1)956+957+ renderState := buildRenderGridState(m)958+ clue := resolveCellVisualWithState(m, renderState, 0, 0)959+ island := resolveCellVisualWithState(m, renderState, 0, 1)960+961+ if !sameColor(clue.bg, island.bg) {962+ t.Fatal("expected clue and connected island cell to share zone background")963+ }964+ if sameColor(clue.bg, theme.Current().BG) {965+ t.Fatal("expected island zone background to differ from neutral background")966+ }967+}968+969+func TestResolveCellVisualZoneOverrides(t *testing.T) {970+ t.Run("cursor stays cell-local", func(t *testing.T) {971+ mode := NewMode("Mini", "test", 2, 1, 0.3, 5)972+ p := Puzzle{Width: 2, Height: 1, Clues: makeClues(973+ []int{2, 0},974+ )}975+ g, err := New(mode, p)976+ if err != nil {977+ t.Fatalf("New error: %v", err)978+ }979+ m := g.(Model)980+ m.cursor = pointToCursor(1, 0)981+ m.setCellAtCursor(islandCell)982+ m.cursor = pointToCursor(0, 0)983+984+ renderState := buildRenderGridState(m)985+ visual := resolveCellVisualWithState(m, renderState, 0, 0)986+ if !sameColor(visual.bg, game.CursorBG()) {987+ t.Fatal("expected cursor background to override zone background")988+ }989+ if sameColor(visual.bridgeBG, game.CursorBG()) {990+ t.Fatal("expected cursor background to remain cell-local")991+ }992+ })993+994+ t.Run("conflict beats zone color", func(t *testing.T) {995+ mode := NewMode("Mini", "test", 2, 1, 0.3, 5)996+ p := Puzzle{Width: 2, Height: 1, Clues: makeClues(997+ []int{1, 0},998+ )}999+ g, err := New(mode, p)1000+ if err != nil {1001+ t.Fatalf("New error: %v", err)1002+ }1003+ m := g.(Model)1004+ m.cursor = pointToCursor(1, 0)1005+ m.setCellAtCursor(islandCell)1006+1007+ renderState := buildRenderGridState(m)1008+ visual := resolveCellVisualWithState(m, renderState, 0, 0)1009+ if !sameColor(visual.bg, game.ConflictBG()) {1010+ t.Fatal("expected conflict background to override zone background")1011+ }1012+ })1013+1014+ t.Run("solved beats zone color", func(t *testing.T) {1015+ mode := NewMode("Mini", "test", 2, 1, 0.3, 5)1016+ p := Puzzle{Width: 2, Height: 1, Clues: makeClues(1017+ []int{1, 0},1018+ )}1019+ g, err := New(mode, p)1020+ if err != nil {1021+ t.Fatalf("New error: %v", err)1022+ }1023+ m := g.(Model)1024+ m.cursor = pointToCursor(1, 0)1025+ m.setCellAtCursor(seaCell)1026+ if !m.solved {1027+ t.Fatal("expected puzzle to be solved")1028+ }1029+1030+ renderState := buildRenderGridState(m)1031+ visual := resolveCellVisualWithState(m, renderState, 0, 0)1032+ if !sameColor(visual.bg, theme.Current().SuccessBG) {1033+ t.Fatal("expected solved background to override zone background")1034+ }1035+ })1036+}1037+1038+func TestResolveCellVisualCursorUsesCursorMarkers(t *testing.T) {1039+ mode := NewMode("Mini", "test", 3, 2, 0.3, 5)1040+ p := Puzzle{Width: 3, Height: 2, Clues: makeClues(1041+ []int{1, 0, 0},1042+ []int{0, 0, 0},1043+ )}1044+ g, err := New(mode, p)1045+ if err != nil {1046+ t.Fatalf("New error: %v", err)1047+ }1048+ m := g.(Model)1049+1050+ clue := resolveCellVisual(m, 0, 0)1051+ if clue.text != game.CursorLeft+"1"+game.CursorRight {1052+ t.Fatalf("cursor clue text = %q, want %q", clue.text, game.CursorLeft+"1"+game.CursorRight)1053+ }1054+1055+ m.cursor = pointToCursor(1, 0)1056+ m.setCellAtCursor(seaCell)1057+ sea := resolveCellVisual(m, 1, 0)1058+ if sea.text != game.CursorLeft+"~"+game.CursorRight {1059+ t.Fatalf("cursor sea text = %q, want %q", sea.text, game.CursorLeft+"~"+game.CursorRight)1060+ }1061+1062+ m.cursor = pointToCursor(2, 0)1063+ unknown := resolveCellVisual(m, 2, 0)1064+ if unknown.text != game.CursorLeft+" "+game.CursorRight {1065+ t.Fatalf("cursor unknown text = %q, want %q", unknown.text, game.CursorLeft+" "+game.CursorRight)1066+ }1067+}1068+1069+func TestResolveCellVisualTwoDigitClueKeepsFullLabel(t *testing.T) {1070+ mode := NewMode("Mini", "test", 1, 1, 0.3, 12)1071+ p := Puzzle{Width: 1, Height: 1, Clues: makeClues(1072+ []int{12},1073+ )}1074+ g, err := New(mode, p)1075+ if err != nil {1076+ t.Fatalf("New error: %v", err)1077+ }1078+ m := g.(Model)1079+1080+ visual := resolveCellVisual(m, 0, 0)1081+ if visual.text != "12 " {1082+ t.Fatalf("cursor two-digit clue text = %q, want %q", visual.text, "12 ")1083+ }1084+}1085+7891086 // --- Seeded generation determinism (P1) ---7901087 7911088 func TestGenerateSeededDeterministicConcurrent(t *testing.T) {
M nurikabe/solver.go +17 -0
M nurikabe/style.go +112 -26
@@ -9,7 +9,15 @@ import (
99 "github.com/FelineStateMachine/puzzletea/theme"1010 )1111 12-const cellWidth = game.DynamicGridCellWidth12+const (13+ cellWidth = game.DynamicGridCellWidth14+ neutralZone = -115+)16+17+type renderGridState struct {18+ zones [][]int19+ zoneColors map[int]color.Color20+}1321 1422 type cellVisual struct {1523 text string
@@ -23,28 +31,77 @@ type cellVisual struct {
2331 type cellVisualKind int2432 2533 const (26- cellVisualLand cellVisualKind = iota34+ cellVisualNeutral cellVisualKind = iota2735 cellVisualSea2836 cellVisualCursor2937 cellVisualSolved3038 cellVisualConflict3139 )3240 41+func buildRenderGridState(m Model) renderGridState {42+ zones := make([][]int, m.height)43+ for y := range m.height {44+ zones[y] = make([]int, m.width)45+ for x := range m.width {46+ zones[y][x] = neutralZone47+ }48+ }49+50+ _, idx := islandComponents(m.marks, m.clues)51+ if idx == nil {52+ return renderGridState{zones: zones}53+ }54+55+ themeColors := theme.Current().ThemeColors()56+ zoneColors := make(map[int]color.Color)57+ for y := range m.height {58+ for x := range m.width {59+ zone := idx[y][x]60+ if zone < 0 {61+ continue62+ }63+ zones[y][x] = zone64+ if len(themeColors) == 0 {65+ continue66+ }67+ if _, ok := zoneColors[zone]; !ok {68+ zoneColors[zone] = themeColors[zone%len(themeColors)]69+ }70+ }71+ }72+73+ return renderGridState{74+ zones: zones,75+ zoneColors: zoneColors,76+ }77+}78+79+func zoneBackground(renderState renderGridState, zone int) color.Color {80+ if zone < 0 {81+ return nil82+ }83+ return renderState.zoneColors[zone]84+}85+3386 func resolveCellVisual(m Model, x, y int) cellVisual {87+ return resolveCellVisualWithState(m, buildRenderGridState(m), x, y)88+}89+90+func resolveCellVisualWithState(m Model, renderState renderGridState, x, y int) cellVisual {3491 p := theme.Current()35- landBg := theme.Blend(p.BG, p.Success, 0.45)3692 seaBg := theme.Blend(p.BG, p.Secondary, 0.24)3793 c := m.marks[y][x]3894 clue := m.clues[y][x]3995 isCursor := x == m.cursor.X && y == m.cursor.Y4096 conflict := m.conflicts[y][x]4197 inSeaSquare := isSeaSquareCell(m.marks, x, y)98+ islandBg := zoneBackground(renderState, renderState.zones[y][x])4299 visual := cellVisual{43100 text: " ",44- fg: theme.TextOnBG(landBg),45- bg: landBg,46- bridgeBG: landBg,47- kind: cellVisualLand,101+ fg: p.FG,102+ bg: p.BG,103+ bridgeBG: p.BG,104+ kind: cellVisualNeutral,48105 }49106 50107 switch {
@@ -53,7 +110,13 @@ func resolveCellVisual(m Model, x, y int) cellVisual {
53110 if clue < 10 {54111 visual.text = fmt.Sprintf(" %d ", clue)55112 }56- visual.fg = p.Info113+ if islandBg != nil {114+ visual.bg = islandBg115+ visual.bridgeBG = islandBg116+ visual.fg = theme.TextOnBG(islandBg)117+ } else {118+ visual.fg = p.Info119+ }57120 visual.bold = true58121 case c == seaCell:59122 visual.text = " ~ "
@@ -66,6 +129,13 @@ func resolveCellVisual(m Model, x, y int) cellVisual {
66129 visual.kind = cellVisualSea67130 case c == islandCell:68131 visual.text = " \u00b7 "132+ if islandBg != nil {133+ visual.bg = islandBg134+ visual.bridgeBG = islandBg135+ visual.fg = theme.TextOnBG(islandBg)136+ }137+ case m.solved && c == unknownCell:138+ visual.text = " \u00b7 "69139 }70140 71141 switch {
@@ -84,10 +154,24 @@ func resolveCellVisual(m Model, x, y int) cellVisual {
84154 visual.fg = game.CursorFG()85155 visual.kind = cellVisualCursor86156 }157+ if isCursor {158+ visual.text = cursorWrappedText(visual.text)159+ }87160 88161 return visual89162 }90163 164+func cursorWrappedText(text string) string {165+ runes := []rune(text)166+ if len(runes) != cellWidth {167+ return text168+ }169+ if runes[0] != ' ' || runes[cellWidth-1] != ' ' {170+ return text171+ }172+ return game.CursorLeft + string(runes[1]) + game.CursorRight173+}174+91175 func renderCellVisual(visual cellVisual) string {92176 style := lipgloss.NewStyle().93177 Width(cellWidth).
@@ -100,20 +184,10 @@ func renderCellVisual(visual cellVisual) string {
100184 return style.Render(visual.text)101185 }102186 103-func colorsMatch(left, right color.Color) bool {104- if left == nil || right == nil {105- return left == nil && right == nil106- }107- lr, lg, lb, la := left.RGBA()108- rr, rg, rb, ra := right.RGBA()109- return lr == rr && lg == rg && lb == rb && la == ra110-}111-112187 func dominantBridgeBackground(visuals []cellVisual) color.Color {113188 for _, kind := range []cellVisualKind{114189 cellVisualConflict,115190 cellVisualSolved,116- cellVisualCursor,117191 } {118192 for _, visual := range visuals {119193 if visual.kind == kind {
@@ -148,6 +222,10 @@ func blendBridgeBackgrounds(visuals []cellVisual) color.Color {
148222 }149223 150224 func bridgeFill(m Model, bridge game.DynamicGridBridge) color.Color {225+ return bridgeFillWithState(m, buildRenderGridState(m), bridge)226+}227+228+func bridgeFillWithState(m Model, renderState renderGridState, bridge game.DynamicGridBridge) color.Color {151229 if bridge.Count == 0 {152230 return nil153231 }
@@ -155,18 +233,24 @@ func bridgeFill(m Model, bridge game.DynamicGridBridge) color.Color {
155233 visuals := make([]cellVisual, 0, bridge.Count)156234 for i := 0; i < bridge.Count; i++ {157235 cell := bridge.Cells[i]158- visuals = append(visuals, resolveCellVisual(m, cell.X, cell.Y))236+ visuals = append(visuals, resolveCellVisualWithState(m, renderState, cell.X, cell.Y))159237 }160238 161239 bg := visuals[0].bridgeBG162240 allMatch := true163241 for _, visual := range visuals[1:] {164- if !colorsMatch(bg, visual.bridgeBG) {242+ if !game.SameColor(bg, visual.bridgeBG) {165243 allMatch = false166244 break167245 }168246 }169247 if allMatch {248+ if dominant := dominantBridgeBackground(visuals); dominant != nil {249+ return dominant250+ }251+ if bridge.Uniform && bridge.Zone >= 0 {252+ return nil253+ }170254 return bg171255 }172256
@@ -178,21 +262,23 @@ func bridgeFill(m Model, bridge game.DynamicGridBridge) color.Color {
178262 }179263 180264 func gridView(m Model) string {265+ renderState := buildRenderGridState(m)266+181267 return game.RenderDynamicGrid(game.DynamicGridSpec{182268 Width: m.width,183269 Height: m.height,184270 Solved: m.solved,185271 Cell: func(x, y int) string {186- return renderCellVisual(resolveCellVisual(m, x, y))272+ return renderCellVisual(resolveCellVisualWithState(m, renderState, x, y))187273 },188- HasVerticalEdge: func(x, _ int) bool {189- return x <= 0 || x >= m.width274+ ZoneAt: func(x, y int) int {275+ return renderState.zones[y][x]190276 },191- HasHorizontalEdge: func(_, y int) bool {192- return y <= 0 || y >= m.height277+ ZoneFill: func(zone int) color.Color {278+ return zoneBackground(renderState, zone)193279 },194280 BridgeFill: func(bridge game.DynamicGridBridge) color.Color {195- return bridgeFill(m, bridge)281+ return bridgeFillWithState(m, renderState, bridge)196282 },197283 })198284 }
M pdfexport/jsonl.go +2 -4
M pdfexport/jsonl_test.go +1 -2
A pdfexport/print_adapter.go +64 -0
@@ -0,0 +1,64 @@
1+package pdfexport2+3+import (4+ "reflect"5+ "strings"6+7+ "codeberg.org/go-pdf/fpdf"8+)9+10+type PrintAdapter interface {11+ CanonicalGameType() string12+ Aliases() []string13+ BuildPDFPayload(save []byte) (any, error)14+ RenderPDFBody(pdf *fpdf.Fpdf, payload any) error15+}16+17+var printAdapterRegistry = map[string]PrintAdapter{}18+19+func RegisterPrintAdapter(adapter PrintAdapter) {20+ canonical := normalizeGameTypeToken(adapter.CanonicalGameType())21+ if canonical == "" {22+ return23+ }24+25+ printAdapterRegistry[canonical] = adapter26+ for _, alias := range adapter.Aliases() {27+ normalized := normalizeGameTypeToken(alias)28+ if normalized == "" {29+ continue30+ }31+ printAdapterRegistry[normalized] = adapter32+ }33+}34+35+func LookupPrintAdapter(gameType string) (PrintAdapter, bool) {36+ adapter, ok := printAdapterRegistry[normalizeGameTypeToken(gameType)]37+ return adapter, ok38+}39+40+func HasPrintAdapter(gameType string) bool {41+ _, ok := LookupPrintAdapter(gameType)42+ return ok43+}44+45+func IsNilPrintPayload(payload any) bool {46+ if payload == nil {47+ return true48+ }49+50+ v := reflect.ValueOf(payload)51+ switch v.Kind() {52+ case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:53+ return v.IsNil()54+ default:55+ return false56+ }57+}58+59+func normalizeGameTypeToken(s string) string {60+ s = strings.ToLower(strings.TrimSpace(s))61+ s = strings.ReplaceAll(s, "-", " ")62+ s = strings.ReplaceAll(s, "_", " ")63+ return strings.Join(strings.Fields(s), " ")64+}
M pdfexport/render.go +4 -5
M pdfexport/render_error_test.go +1 -2
A puzzle/puzzle.go +47 -0
A registry/registry.go +220 -0
@@ -0,0 +1,220 @@
1+package registry2+3+import (4+ "fmt"5+6+ "github.com/FelineStateMachine/puzzletea/catalog"7+ "github.com/FelineStateMachine/puzzletea/fillomino"8+ "github.com/FelineStateMachine/puzzletea/game"9+ "github.com/FelineStateMachine/puzzletea/hashiwokakero"10+ "github.com/FelineStateMachine/puzzletea/hitori"11+ "github.com/FelineStateMachine/puzzletea/lightsout"12+ "github.com/FelineStateMachine/puzzletea/nonogram"13+ "github.com/FelineStateMachine/puzzletea/nurikabe"14+ "github.com/FelineStateMachine/puzzletea/puzzle"15+ "github.com/FelineStateMachine/puzzletea/rippleeffect"16+ "github.com/FelineStateMachine/puzzletea/shikaku"17+ "github.com/FelineStateMachine/puzzletea/sudoku"18+ "github.com/FelineStateMachine/puzzletea/sudokurgb"19+ "github.com/FelineStateMachine/puzzletea/takuzu"20+ "github.com/FelineStateMachine/puzzletea/takuzuplus"21+ "github.com/FelineStateMachine/puzzletea/wordsearch"22+)23+24+type ModeEntry struct {25+ Definition puzzle.ModeDef26+ Spawner game.Spawner27+ Seeded game.SeededSpawner28+}29+30+type Entry struct {31+ Definition puzzle.Definition32+ Help string33+ Import func([]byte) (game.Gamer, error)34+ Modes []ModeEntry35+}36+37+type DailyEntry struct {38+ Spawner game.SeededSpawner39+ GameID puzzle.GameID40+ GameType string41+ ModeID puzzle.ModeID42+ Mode string43+}44+45+var all = []Entry{46+ adaptLegacy(fillomino.Definition),47+ adaptLegacy(hashiwokakero.Definition),48+ adaptLegacy(hitori.Definition),49+ adaptLegacy(lightsout.Definition),50+ adaptLegacy(nonogram.Definition),51+ adaptLegacy(nurikabe.Definition),52+ adaptLegacy(rippleeffect.Definition),53+ adaptLegacy(shikaku.Definition),54+ adaptLegacy(sudoku.Definition),55+ adaptLegacy(sudokurgb.Definition),56+ adaptLegacy(takuzu.Definition),57+ adaptLegacy(takuzuplus.Definition),58+ adaptLegacy(wordsearch.Definition),59+}60+61+var (62+ definitions = buildDefinitions(all)63+ index = catalog.MustBuild(definitions)64+ entriesByID = buildEntriesByID(all)65+)66+67+func adaptLegacy(def game.Definition) Entry {68+ gameID := puzzle.CanonicalGameID(def.Name)69+ modes := make([]ModeEntry, 0, len(def.Modes))70+ modeIDs := make([]puzzle.ModeID, 0, len(def.Modes))71+ for _, item := range def.Modes {72+ mode, ok := item.(game.Mode)73+ if !ok {74+ continue75+ }76+ spawner, ok := item.(game.Spawner)77+ if !ok {78+ continue79+ }80+ modeID := puzzle.CanonicalModeID(mode.Title())81+ modeDef := puzzle.ModeDef{82+ ID: modeID,83+ Title: mode.Title(),84+ Description: mode.Description(),85+ }86+ var seeded game.SeededSpawner87+ if s, ok := item.(game.SeededSpawner); ok {88+ modeDef.Seeded = true89+ seeded = s90+ }91+ modes = append(modes, ModeEntry{92+ Definition: modeDef,93+ Spawner: spawner,94+ Seeded: seeded,95+ })96+ modeIDs = append(modeIDs, modeID)97+ }98+99+ dailyIDs := make([]puzzle.ModeID, 0, len(def.DailyModes))100+ for _, item := range def.DailyModes {101+ mode, ok := item.(game.Mode)102+ if !ok {103+ continue104+ }105+ dailyIDs = append(dailyIDs, puzzle.CanonicalModeID(mode.Title()))106+ }107+108+ return Entry{109+ Definition: puzzle.Definition{110+ ID: gameID,111+ Name: def.Name,112+ Description: def.Description,113+ Aliases: append([]string(nil), def.Aliases...),114+ Modes: extractModeDefs(modes),115+ DailyModeIDs: dailyIDs,116+ },117+ Help: def.Help,118+ Import: def.Import,119+ Modes: modes,120+ }121+}122+123+func extractModeDefs(modes []ModeEntry) []puzzle.ModeDef {124+ defs := make([]puzzle.ModeDef, 0, len(modes))125+ for _, mode := range modes {126+ defs = append(defs, mode.Definition)127+ }128+ return defs129+}130+131+func buildDefinitions(entries []Entry) []puzzle.Definition {132+ defs := make([]puzzle.Definition, 0, len(entries))133+ for _, entry := range entries {134+ defs = append(defs, entry.Definition)135+ }136+ return defs137+}138+139+func buildEntriesByID(entries []Entry) map[puzzle.GameID]Entry {140+ result := make(map[puzzle.GameID]Entry, len(entries))141+ for _, entry := range entries {142+ result[entry.Definition.ID] = entry143+ }144+ return result145+}146+147+func Entries() []Entry {148+ return append([]Entry(nil), all...)149+}150+151+func Definitions() []puzzle.Definition {152+ return index.Definitions()153+}154+155+func Names() []string {156+ return index.Names()157+}158+159+func Lookup(name string) (Entry, bool) {160+ def, ok := index.Lookup(name)161+ if !ok {162+ return Entry{}, false163+ }164+ entry, ok := entriesByID[def.ID]165+ return entry, ok166+}167+168+func Resolve(name string) (Entry, bool) {169+ def, ok := index.Resolve(name)170+ if !ok {171+ return Entry{}, false172+ }173+ entry, ok := entriesByID[def.ID]174+ return entry, ok175+}176+177+func Import(gameType string, data []byte) (game.Gamer, error) {178+ entry, ok := Lookup(gameType)179+ if !ok {180+ return nil, fmt.Errorf("unknown game type %q in save data", gameType)181+ }182+ g, err := entry.Import(data)183+ if err != nil {184+ return nil, fmt.Errorf("failed to import game: %w", err)185+ }186+ return g, nil187+}188+189+func DailyEntries() []DailyEntry {190+ dailies := index.DailyEntries()191+ result := make([]DailyEntry, 0, len(dailies))192+ for _, daily := range dailies {193+ entry, ok := entriesByID[daily.GameID]194+ if !ok {195+ continue196+ }197+ for _, mode := range entry.Modes {198+ if mode.Definition.ID != daily.ModeID || mode.Seeded == nil {199+ continue200+ }201+ result = append(result, DailyEntry{202+ Spawner: mode.Seeded,203+ GameID: daily.GameID,204+ GameType: entry.Definition.Name,205+ ModeID: daily.ModeID,206+ Mode: mode.Definition.Title,207+ })208+ break209+ }210+ }211+ return result212+}213+214+func Help(name string) (string, bool) {215+ entry, ok := Resolve(name)216+ if !ok {217+ return "", false218+ }219+ return entry.Help, true220+}
M resolve/resolve.go +38 -63
@@ -8,64 +8,44 @@ import (
88 "strings"99 1010 "github.com/FelineStateMachine/puzzletea/game"11+ "github.com/FelineStateMachine/puzzletea/puzzle"12+ "github.com/FelineStateMachine/puzzletea/registry"1113 )1214 1315 // Normalize lowercases and replaces hyphens/underscores with spaces for1416 // fuzzy matching of CLI arguments to mode names.1517 func Normalize(s string) string {16- return game.NormalizeName(s)18+ return puzzle.NormalizeName(s)1719 }1820 19-// Mode finds a mode within a category by name. If name is empty,21+// Mode finds a mode within a game entry by name. If name is empty,2022 // returns the first (default) mode.21-func Mode(cat game.Category, name string) (game.Spawner, string, error) {22- if len(cat.Modes) == 0 {23- return nil, "", fmt.Errorf("game %q has no available modes", cat.Name)23+func Mode(entry registry.Entry, name string) (game.Spawner, string, error) {24+ if len(entry.Modes) == 0 {25+ return nil, "", fmt.Errorf("game %q has no available modes", entry.Definition.Name)2426 }2527 2628 if name == "" {27- for _, item := range cat.Modes {28- m, ok := item.(game.Mode)29- if !ok {30- continue31- }32- s, ok := item.(game.Spawner)33- if !ok {34- continue35- }36- return s, m.Title(), nil37- }38- return nil, "", fmt.Errorf("game %q has no spawnable modes", cat.Name)29+ mode := entry.Modes[0]30+ return mode.Spawner, mode.Definition.Title, nil3931 }4032 4133 norm := Normalize(name)42- for _, item := range cat.Modes {43- m, ok := item.(game.Mode)44- if !ok {45- continue46- }47- if Normalize(m.Title()) == norm {48- s, ok := item.(game.Spawner)49- if !ok {50- return nil, "", fmt.Errorf("mode %q for %s is not spawnable", m.Title(), cat.Name)51- }52- return s, m.Title(), nil34+ for _, mode := range entry.Modes {35+ if Normalize(mode.Definition.Title) == norm {36+ return mode.Spawner, mode.Definition.Title, nil5337 }5438 }5539 5640 return nil, "", fmt.Errorf("unknown mode %q for %s\n\nAvailable modes:\n %s",57- name, cat.Name, strings.Join(ModeNames(cat), "\n "))41+ name, entry.Definition.Name, strings.Join(ModeNames(entry), "\n "))5842 }5943 60-// ModeNames returns the display names of all modes in a category.61-func ModeNames(cat game.Category) []string {62- names := make([]string, 0, len(cat.Modes))63- for _, item := range cat.Modes {64- mode, ok := item.(game.Mode)65- if !ok {66- continue67- }68- names = append(names, mode.Title())44+// ModeNames returns the display names of all modes in an entry.45+func ModeNames(entry registry.Entry) []string {46+ names := make([]string, 0, len(entry.Modes))47+ for _, mode := range entry.Modes {48+ names = append(names, mode.Definition.Title)6949 }7050 return names7151 }
@@ -86,33 +66,28 @@ type seededEntry struct {
8666 mode string8767 }8868 89-func seededModeForDefinition(seed string, def game.Definition) (seededEntry, bool) {69+func seededModeForDefinition(seed string, entry registry.Entry) (seededEntry, bool) {9070 var best seededEntry9171 var bestHash uint649272 found := false9373 94- for _, modeItem := range def.Modes {95- mode, ok := modeItem.(game.Mode)96- if !ok {97- continue98- }99- s, ok := modeItem.(game.SeededSpawner)100- if !ok {74+ for _, mode := range entry.Modes {75+ if mode.Seeded == nil {10176 continue10277 }10378 h := fnv.New64a()10479 h.Write([]byte(seed))10580 h.Write([]byte{0})106- h.Write([]byte(def.Name))81+ h.Write([]byte(entry.Definition.Name))10782 h.Write([]byte{0})108- h.Write([]byte(mode.Title()))83+ h.Write([]byte(mode.Definition.Title))10984 score := h.Sum64()11085 if !found || score > bestHash {11186 bestHash = score11287 best = seededEntry{113- spawner: s,114- gameType: def.Name,115- mode: mode.Title(),88+ spawner: mode.Seeded,89+ gameType: entry.Definition.Name,90+ mode: mode.Definition.Title,11691 }11792 found = true11893 }
@@ -129,25 +104,25 @@ func seededModeForDefinition(seed string, def game.Definition) (seededEntry, boo
129104 // This is resilient to changes in the category/mode lists: adding or130105 // removing a mode only affects seeds where the changed entry would have131106 // been the winner.132-func SeededMode(seed string, definitions []game.Definition) (game.SeededSpawner, string, string, error) {107+func SeededMode(seed string, entries []registry.Entry) (game.SeededSpawner, string, string, error) {133108 var best seededEntry134109 var bestHash uint64135110 found := false136- for _, def := range definitions {137- entry, ok := seededModeForDefinition(seed, def)111+ for _, entry := range entries {112+ selected, ok := seededModeForDefinition(seed, entry)138113 if !ok {139114 continue140115 }141116 h := fnv.New64a()142117 h.Write([]byte(seed))143118 h.Write([]byte{0})144- h.Write([]byte(entry.gameType))119+ h.Write([]byte(selected.gameType))145120 h.Write([]byte{0})146- h.Write([]byte(entry.mode))121+ h.Write([]byte(selected.mode))147122 score := h.Sum64()148123 if !found || score > bestHash {149124 bestHash = score150- best = entry125+ best = selected151126 found = true152127 }153128 }
@@ -160,18 +135,18 @@ func SeededMode(seed string, definitions []game.Definition) (game.SeededSpawner,
160135 // SeededModeForGame deterministically selects a seeded mode within a single161136 // game definition, so the same seed and game name always produce the same162137 // puzzle for that game.163-func SeededModeForGame(seed, gameType string, definitions []game.Definition) (game.SeededSpawner, string, string, error) {138+func SeededModeForGame(seed, gameType string, entries []registry.Entry) (game.SeededSpawner, string, string, error) {164139 norm := Normalize(gameType)165- for _, def := range definitions {166- if Normalize(def.Name) != norm {140+ for _, entry := range entries {141+ if Normalize(entry.Definition.Name) != norm {167142 continue168143 }169144 170- entry, ok := seededModeForDefinition(seed, def)145+ selected, ok := seededModeForDefinition(seed, entry)171146 if !ok {172- return nil, "", "", fmt.Errorf("game %q has no seeded modes", def.Name)147+ return nil, "", "", fmt.Errorf("game %q has no seeded modes", entry.Definition.Name)173148 }174- return entry.spawner, entry.gameType, entry.mode, nil149+ return selected.spawner, selected.gameType, selected.mode, nil175150 }176151 177152 return nil, "", "", fmt.Errorf("unknown game %q", gameType)
M resolve/resolve_test.go +86 -198
@@ -4,13 +4,13 @@ import (
44 "strings"55 "testing"66 7- "charm.land/bubbles/v2/list"8- "github.com/FelineStateMachine/puzzletea/catalog"97 "github.com/FelineStateMachine/puzzletea/game"108 "github.com/FelineStateMachine/puzzletea/lightsout"9+ "github.com/FelineStateMachine/puzzletea/puzzle"10+ "github.com/FelineStateMachine/puzzletea/registry"1111 )1212 13-var testDefinitions = catalog.All13+var testEntries = registry.Entries()1414 1515 func TestNormalize(t *testing.T) {1616 tests := []struct {
@@ -22,19 +22,11 @@ func TestNormalize(t *testing.T) {
2222 {"all caps", "SUDOKU", "sudoku"},2323 {"hyphens to spaces", "lights-out", "lights out"},2424 {"underscores to spaces", "word_search", "word search"},25- {"mixed separators", "Word-Search_Test", "word search test"},26- {"already normalized", "sudoku", "sudoku"},27- {"leading trailing space", " sudoku ", "sudoku"},28- {"empty string", "", ""},29- {"only hyphens", "---", ""},30- {"only underscores", "___", ""},31- {"mixed with spaces", " lights-out ", "lights out"},3225 }3326 3427 for _, tt := range tests {3528 t.Run(tt.name, func(t *testing.T) {36- got := Normalize(tt.input)37- if got != tt.want {29+ if got := Normalize(tt.input); got != tt.want {3830 t.Errorf("Normalize(%q) = %q, want %q", tt.input, got, tt.want)3931 }4032 })
@@ -42,243 +34,139 @@ func TestNormalize(t *testing.T) {
4234 }4335 4436 func TestMode(t *testing.T) {45- // Find a known category with modes for testing.46- sudokuCat, err := catalog.Category("Sudoku")47- if err != nil {48- t.Fatal(err)37+ sudokuEntry, ok := registry.Resolve("Sudoku")38+ if !ok {39+ t.Fatal("missing sudoku entry")4940 }5041 5142 t.Run("empty name returns default (first) mode", func(t *testing.T) {52- spawner, title, err := Mode(sudokuCat, "")43+ spawner, title, err := Mode(sudokuEntry, "")5344 if err != nil {5445 t.Fatal(err)5546 }56- if spawner == nil {57- t.Fatal("expected non-nil spawner")58- }59- // First Sudoku mode is "Beginner".60- if title != "Beginner" {61- t.Errorf("default mode title = %q, want %q", title, "Beginner")47+ if spawner == nil || title != "Beginner" {48+ t.Fatalf("default mode = (%v, %q), want Beginner", spawner != nil, title)6249 }6350 })6451 65- t.Run("exact mode title", func(t *testing.T) {66- _, title, err := Mode(sudokuCat, "Easy")52+ t.Run("case insensitive match", func(t *testing.T) {53+ _, title, err := Mode(sudokuEntry, "easy")6754 if err != nil {6855 t.Fatal(err)6956 }7057 if title != "Easy" {71- t.Errorf("title = %q, want %q", title, "Easy")72- }73- })74-75- t.Run("case insensitive", func(t *testing.T) {76- _, title, err := Mode(sudokuCat, "easy")77- if err != nil {78- t.Fatal(err)79- }80- if title != "Easy" {81- t.Errorf("title = %q, want %q", title, "Easy")58+ t.Fatalf("title = %q, want Easy", title)8259 }8360 })8461 8562 t.Run("unknown mode", func(t *testing.T) {86- _, _, err := Mode(sudokuCat, "impossible")87- if err == nil {88- t.Fatal("expected error for unknown mode")89- }90- if !strings.Contains(err.Error(), "unknown mode") {91- t.Errorf("error = %q, want it to contain %q", err.Error(), "unknown mode")63+ _, _, err := Mode(sudokuEntry, "impossible")64+ if err == nil || !strings.Contains(err.Error(), "unknown mode") {65+ t.Fatalf("error = %v, want unknown mode", err)9266 }9367 })9468 9569 t.Run("empty modes list", func(t *testing.T) {96- emptyCat := game.Category{Name: "Empty", Modes: nil}97- _, _, err := Mode(emptyCat, "any")98- if err == nil {99- t.Fatal("expected error for empty modes")70+ emptyEntry := registry.Entry{71+ Definition: puzzle.Definition{Name: "Empty"},10072 }101- if !strings.Contains(err.Error(), "no available modes") {102- t.Errorf("error = %q, want it to contain %q", err.Error(), "no available modes")73+ _, _, err := Mode(emptyEntry, "any")74+ if err == nil || !strings.Contains(err.Error(), "no available modes") {75+ t.Fatalf("error = %v, want no available modes", err)10376 }10477 })10578 }10679 107-func TestModeSupportsSudokuRGBAliasCategory(t *testing.T) {108- cat, err := catalog.Category("ripeto")109- if err != nil {110- t.Fatal(err)111- }112- if cat.Name != "Sudoku RGB" {113- t.Fatalf("category name = %q, want %q", cat.Name, "Sudoku RGB")114- }115-116- _, title, err := Mode(cat, "easy")117- if err != nil {118- t.Fatal(err)119- }120- if title != "Easy" {121- t.Fatalf("mode title = %q, want %q", title, "Easy")122- }123-}124-12580 func TestModeNames(t *testing.T) {126- cat, err := catalog.Category("Lights Out")127- if err != nil {128- t.Fatal(err)129- }130-131- names := ModeNames(cat)132- if len(names) != len(cat.Modes) {133- t.Fatalf("len(ModeNames) = %d, want %d", len(names), len(cat.Modes))81+ entry, ok := registry.Resolve("Lights Out")82+ if !ok {83+ t.Fatal("missing lights out entry")13484 }135-136- // Lights Out modes: Easy, Medium, Hard, Extreme.85+ names := ModeNames(entry)13786 expected := []string{"Easy", "Medium", "Hard", "Extreme"}87+ if len(names) != len(expected) {88+ t.Fatalf("len(ModeNames) = %d, want %d", len(names), len(expected))89+ }13890 for i, want := range expected {13991 if names[i] != want {140- t.Errorf("mode[%d] = %q, want %q", i, names[i], want)92+ t.Fatalf("mode[%d] = %q, want %q", i, names[i], want)14193 }14294 }14395 }14496 145-// --- RNGFromString (P0) ---146-14797 func TestRNGFromString(t *testing.T) {148- t.Run("deterministic output", func(t *testing.T) {149- rng1 := RNGFromString("hello-world")150- rng2 := RNGFromString("hello-world")151-152- // Draw several values and confirm they match.153- for i := range 10 {154- v1 := rng1.Uint64()155- v2 := rng2.Uint64()156- if v1 != v2 {157- t.Fatalf("draw %d: got %d and %d for same seed", i, v1, v2)158- }98+ rng1 := RNGFromString("hello-world")99+ rng2 := RNGFromString("hello-world")100+ for i := range 10 {101+ if v1, v2 := rng1.Uint64(), rng2.Uint64(); v1 != v2 {102+ t.Fatalf("draw %d: got %d and %d for same seed", i, v1, v2)159103 }160- })104+ }105+}161106 162- t.Run("different seeds produce different output", func(t *testing.T) {163- rng1 := RNGFromString("seed-a")164- rng2 := RNGFromString("seed-b")107+func TestSeededMode(t *testing.T) {108+ s1, gt1, m1, err := SeededMode("test-seed-42", testEntries)109+ if err != nil {110+ t.Fatal(err)111+ }112+ s2, gt2, m2, err := SeededMode("test-seed-42", testEntries)113+ if err != nil {114+ t.Fatal(err)115+ }116+ if s1 == nil || s2 == nil || gt1 != gt2 || m1 != m2 {117+ t.Fatalf("seeded mode mismatch: (%v,%q,%q) vs (%v,%q,%q)", s1 != nil, gt1, m1, s2 != nil, gt2, m2)118+ }165119 166- // Extremely unlikely for two different seeds to produce the same first draw.167- if rng1.Uint64() == rng2.Uint64() {168- t.Error("different seeds produced identical first draw")169- }170- })120+ _, _, _, err = SeededMode("test", nil)121+ if err == nil {122+ t.Fatal("expected error for empty entries")123+ }171124 172- t.Run("empty string is valid", func(t *testing.T) {173- rng := RNGFromString("")174- // Should not panic; just verify it produces output.175- _ = rng.Uint64()176- })125+ emptyEntries := []registry.Entry{{126+ Definition: puzzle.Definition{Name: "Empty"},127+ }}128+ _, _, _, err = SeededMode("test", emptyEntries)129+ if err == nil {130+ t.Fatal("expected error when no seeded modes available")131+ }177132 }178133 179-// --- SeededMode (P0) ---180-181-func TestSeededMode(t *testing.T) {182- t.Run("deterministic selection", func(t *testing.T) {183- s1, gt1, m1, err := SeededMode("test-seed-42", testDefinitions)184- if err != nil {185- t.Fatal(err)186- }187- s2, gt2, m2, err := SeededMode("test-seed-42", testDefinitions)134+func TestSeededModeStableOnEntryChange(t *testing.T) {135+ seeds := []string{"alpha", "bravo", "charlie", "delta"}136+ type selection struct{ gameType, mode string }137+ original := make([]selection, len(seeds))138+ for i, seed := range seeds {139+ _, gt, m, err := SeededMode(seed, testEntries)188140 if err != nil {189141 t.Fatal(err)190142 }143+ original[i] = selection{gt, m}144+ }191145 192- if gt1 != gt2 {193- t.Errorf("game types differ: %q vs %q", gt1, gt2)194- }195- if m1 != m2 {196- t.Errorf("mode titles differ: %q vs %q", m1, m2)197- }198- if s1 == nil || s2 == nil {199- t.Fatal("expected non-nil spawners")200- }146+ extended := append([]registry.Entry(nil), testEntries...)147+ extended = append(extended, registry.Entry{148+ Definition: puzzle.Definition{149+ ID: puzzle.CanonicalGameID("Synthetic"),150+ Name: "Synthetic",151+ Modes: []puzzle.ModeDef{{ID: puzzle.CanonicalModeID("Easy"), Title: "Easy", Seeded: true}},152+ },153+ Modes: []registry.ModeEntry{{154+ Definition: puzzle.ModeDef{ID: puzzle.CanonicalModeID("Easy"), Title: "Easy", Seeded: true},155+ Spawner: lightsout.Modes[0].(game.Spawner),156+ Seeded: lightsout.Modes[0].(game.SeededSpawner),157+ }},201158 })202159 203- t.Run("returns valid metadata", func(t *testing.T) {204- spawner, gameType, modeTitle, err := SeededMode("test-seed", testDefinitions)160+ for i, seed := range seeds {161+ _, gt, m, err := SeededMode(seed, extended)205162 if err != nil {206163 t.Fatal(err)207164 }208- if spawner == nil {209- t.Fatal("expected non-nil spawner")210- }211- if gameType == "" {212- t.Error("expected non-empty game type")213- }214- if modeTitle == "" {215- t.Error("expected non-empty mode title")216- }217- })218-219- t.Run("empty categories returns error", func(t *testing.T) {220- _, _, _, err := SeededMode("test", nil)221- if err == nil {222- t.Fatal("expected error for empty categories")223- }224- })225-226- t.Run("categories with no seeded spawners returns error", func(t *testing.T) {227- // Category with no modes at all.228- cats := []game.Definition{229- {Name: "Empty", Modes: nil, Import: func([]byte) (game.Gamer, error) { return nil, nil }},230- }231- _, _, _, err := SeededMode("test", cats)232- if err == nil {233- t.Fatal("expected error when no seeded modes available")234- }235- })236-237- t.Run("stable on category change", func(t *testing.T) {238- // Verify rendezvous hashing stability: adding a category should239- // only affect seeds where the new category's mode wins.240- seeds := []string{241- "alpha", "bravo", "charlie", "delta", "echo",242- "foxtrot", "golf", "hotel", "india", "juliet",243- }244-245- type selection struct {246- gameType string247- mode string248- }249-250- // Record selections with original categories.251- original := make([]selection, len(seeds))252- for i, seed := range seeds {253- _, gt, m, err := SeededMode(seed, testDefinitions)254- if err != nil {255- t.Fatal(err)256- }257- original[i] = selection{gt, m}165+ if gt == "Synthetic" {166+ continue258167 }259-260- // Add a synthetic category with one mode.261- extended := make([]game.Definition, len(testDefinitions))262- copy(extended, testDefinitions)263- extended = append(extended, game.Definition{264- Name: "Synthetic",265- Description: "Fake game for testing",266- Modes: []list.Item{lightsout.Modes[0]},267- Import: func([]byte) (game.Gamer, error) { return nil, nil },268- })269-270- for i, seed := range seeds {271- _, gt, m, err := SeededMode(seed, extended)272- if err != nil {273- t.Fatal(err)274- }275- if gt == "Synthetic" {276- continue // this seed was "stolen" by the new entry277- }278- if gt != original[i].gameType || m != original[i].mode {279- t.Errorf("seed %q: selection changed from (%q,%q) to (%q,%q) after adding unrelated category",280- seed, original[i].gameType, original[i].mode, gt, m)281- }168+ if gt != original[i].gameType || m != original[i].mode {169+ t.Fatalf("seed %q changed from (%q,%q) to (%q,%q)", seed, original[i].gameType, original[i].mode, gt, m)282170 }283- })171+ }284172 }
M rippleeffect/Gamemode.go +1 -1
M rippleeffect/PrintAdapter.go +1 -2
M rippleeffect/style.go +1 -25
M session/session.go +13 -2
M shikaku/Gamemode.go +1 -1
M shikaku/PrintAdapter.go +1 -2
A stats/layout.go +56 -0
M stats/stats.go +15 -317
@@ -1,18 +1,12 @@
11 package stats22 33 import (4- "fmt"5- "image/color"64 "math"75 "slices"8- "strings"96 "time"107 11- "github.com/FelineStateMachine/puzzletea/game"8+ "github.com/FelineStateMachine/puzzletea/puzzle"129 "github.com/FelineStateMachine/puzzletea/store"13- "github.com/FelineStateMachine/puzzletea/theme"14-15- "charm.land/lipgloss/v2"1610 )1711 1812 // ModeKey identifies a specific mode within a game category.
@@ -21,27 +15,21 @@ type ModeKey struct {
2115 Mode string2216 }2317 24-// ModeXP maps each mode to its base XP award per completion.25-var ModeXP map[ModeKey]int18+type Weights map[ModeKey]int2619 27-// InitModeXP builds the ModeXP map from the provided game categories.28-// Must be called once at startup before any stats operations.29-func InitModeXP(categories []game.Category) {30- ModeXP = make(map[ModeKey]int, 64)31- for _, cat := range categories {32- count := len(cat.Modes)33- for i, m := range cat.Modes {34- mode, ok := m.(game.Mode)35- if !ok {36- continue37- }20+func WeightsFromDefinitions(definitions []puzzle.Definition) Weights {21+ weights := make(Weights, 64)22+ for _, def := range definitions {23+ count := len(def.Modes)24+ for i, mode := range def.Modes {3825 xp := int(math.Round(float64(i) / float64(count) * 10))3926 if xp < 1 {4027 xp = 14128 }42- ModeXP[ModeKey{cat.Name, mode.Title()}] = xp29+ weights[ModeKey{GameType: def.Name, Mode: mode.Title}] = xp4330 }4431 }32+ return weights4533 }4634 4735 // LevelFromXP returns the level for the given total XP.
@@ -64,13 +52,13 @@ func XPForLevel(level int) int {
6452 6553 // ComputeCategoryXP calculates total XP for a game category by summing6654 // XP across all modes. Daily victories earn 2x XP.67-func ComputeCategoryXP(gameType string, modeStats []store.ModeStat) int {55+func ComputeCategoryXP(weights Weights, gameType string, modeStats []store.ModeStat) int {6856 total := 06957 for _, ms := range modeStats {7058 if ms.GameType != gameType {7159 continue7260 }73- baseXP := ModeXP[ModeKey{ms.GameType, ms.Mode}]61+ baseXP := weights[ModeKey{ms.GameType, ms.Mode}]7462 if baseXP == 0 {7563 baseXP = 17664 }
@@ -140,13 +128,13 @@ type Card struct {
140128 141129 // BuildCards constructs a Card for each category the player has142130 // interacted with. Categories with zero attempts are excluded.143-func BuildCards(catStats []store.CategoryStat, modeStats []store.ModeStat) []Card {131+func BuildCards(weights Weights, catStats []store.CategoryStat, modeStats []store.ModeStat) []Card {144132 var cards []Card145133 for _, cs := range catStats {146134 if cs.TotalAttempts == 0 {147135 continue148136 }149- totalXP := ComputeCategoryXP(cs.GameType, modeStats)137+ totalXP := ComputeCategoryXP(weights, cs.GameType, modeStats)150138 lvl := LevelFromXP(totalXP)151139 nextXP := XPForLevel(lvl + 1)152140
@@ -194,6 +182,7 @@ type ProfileBanner struct {
194182 func BuildProfileBanner(195183 catStats []store.CategoryStat,196184 modeStats []store.ModeStat,185+ weights Weights,197186 streakDates []time.Time,198187 currentDaily bool,199188 weekliesCompleted int,
@@ -202,7 +191,7 @@ func BuildProfileBanner(
202191 profileLevel := 0203192 totalDailies := 0204193 for _, cs := range catStats {205- xp := ComputeCategoryXP(cs.GameType, modeStats)194+ xp := ComputeCategoryXP(weights, cs.GameType, modeStats)206195 profileLevel += LevelFromXP(xp)207196 totalDailies += cs.TimesDaily208197 }
@@ -218,294 +207,3 @@ func BuildProfileBanner(
218207 ThisWeekHighestIndex: thisWeekHighestIndex,219208 }220209 }221-222-// --- Rendering ---223-224-const (225- CardInnerWidth = 30226- // CardHeight is the rendered height of a single stats card in lines.227- // 7 inner lines (title + 4 stats + blank + XP bar) + 2 border lines = 9.228- CardHeight = 9229-)230-231-// Panel chrome: border top (1) + padding top (1) + title (1) + blank (1)232-// ... content ...233-// blank (1) + footer (1) + padding bottom (1) + border bottom (1) = 8 total.234-const panelChrome = 8235-236-// bannerHeight is the rendered banner (4 lines) plus a blank separator line.237-const bannerHeight = 5238-239-// StaticHeight returns the total lines consumed by non-scrollable parts240-// of the stats view: panel chrome + banner (when cards exist).241-func StaticHeight(cards []Card) int {242- if len(cards) == 0 {243- return panelChrome244- }245- return panelChrome + bannerHeight246-}247-248-// CardFullWidth is the rendered width of a single card including border + padding.249-const CardFullWidth = CardInnerWidth + 4 // 34250-251-const (252- cardColumnGap = 2253- cardRowGap = 2254-)255-256-// ContentWidth returns the inner content width for the stats panel.257-// It snaps to the widest whole-card grid that fits the available panel space.258-// The result never exceeds terminal width minus panel chrome.259-func ContentWidth(termWidth int) int {260- available := max(termWidth-6, CardFullWidth) // panel border (1+1) + padding (2+2)261- cols := cardColumnCount(available)262- width := cardGridWidth(cols)263- if width > available {264- return available265- }266- return width267-}268-269-// ViewportHeight computes the viewport height for the card grid.270-// Shows at most 2.5 card rows and at least 1 full card row, clamped271-// to the available terminal height minus the static chrome.272-func ViewportHeight(availableHeight int) int {273- maxRows := CardHeight*2 + CardHeight/2 // ~2.5 cards = 22274- minRows := CardHeight // 1 full card = 9275- h := min(availableHeight, maxRows)276- return max(h, minRows)277-}278-279-func cardFrameStyle() lipgloss.Style {280- return lipgloss.NewStyle().281- Border(lipgloss.RoundedBorder()).282- BorderForeground(theme.Current().Border).283- Padding(0, 1).284- Width(CardInnerWidth + 4) // +4 for border + padding285-}286-287-func cardTitleStyle(fg color.Color) lipgloss.Style {288- return lipgloss.NewStyle().289- Bold(true).290- Foreground(fg)291-}292-293-func statLabelStyle() lipgloss.Style {294- return lipgloss.NewStyle().295- Foreground(theme.Current().TextDim)296-}297-298-func statValueStyle() lipgloss.Style {299- return lipgloss.NewStyle().300- Foreground(theme.Current().Accent).301- Bold(true)302-}303-304-func xpBarFilledStyle() lipgloss.Style {305- return lipgloss.NewStyle().306- Foreground(theme.Current().Accent)307-}308-309-func xpBarEmptyStyle() lipgloss.Style {310- return lipgloss.NewStyle().311- Foreground(theme.Current().Border)312-}313-314-func xpFractionStyle() lipgloss.Style {315- return lipgloss.NewStyle().316- Foreground(theme.Current().FG)317-}318-319-func bannerRuleStyle() lipgloss.Style {320- return lipgloss.NewStyle().321- Foreground(theme.Current().Border)322-}323-324-func bannerLabelStyle() lipgloss.Style {325- return lipgloss.NewStyle().326- Foreground(theme.Current().FG)327-}328-329-func bannerValueStyle() lipgloss.Style {330- return lipgloss.NewStyle().331- Foreground(theme.Current().Accent).332- Bold(true)333-}334-335-// RenderCard renders a single stats card. The titleColor determines336-// the foreground color used for the category name heading.337-func RenderCard(c Card, titleColor color.Color) string {338- title := cardTitleStyle(titleColor).Render(339- fmt.Sprintf("%s \u2014 LVL %d", strings.ToUpper(c.GameType), c.Level),340- )341-342- lines := []string{343- title,344- renderStatLine("Preferred Mode:", c.PreferredMode),345- renderStatLine("Total Victories:", fmt.Sprintf("%d", c.Victories)),346- renderStatLine("Total Attempts:", fmt.Sprintf("%d", c.Attempts)),347- renderStatLine("Daily Played:", fmt.Sprintf("%d", c.DailyPlayed)),348- "",349- renderXPBar(c.CurrentXP, c.Level),350- }351-352- inner := strings.Join(lines, "\n")353- return cardFrameStyle().Render(inner)354-}355-356-func renderStatLine(label, value string) string {357- l := statLabelStyle().Render(label)358- v := statValueStyle().Render(value)359- // Right-align value within card width.360- gap := CardInnerWidth - lipgloss.Width(l) - lipgloss.Width(v)361- if gap < 1 {362- gap = 1363- }364- return l + strings.Repeat(" ", gap) + v365-}366-367-func renderXPBar(currentXP, level int) string {368- currentLevelXP := XPForLevel(level)369- nextLevelXP := XPForLevel(level + 1)370- xpIntoLevel := currentXP - currentLevelXP371- xpNeeded := nextLevelXP - currentLevelXP372- if xpNeeded <= 0 {373- xpNeeded = 1374- }375-376- const barWidth = 12377- filled := 0378- if xpNeeded > 0 {379- filled = int(float64(xpIntoLevel) / float64(xpNeeded) * barWidth)380- }381- if filled > barWidth {382- filled = barWidth383- }384- if filled < 0 {385- filled = 0386- }387-388- bar := xpBarFilledStyle().Render(strings.Repeat("\u2588", filled)) +389- xpBarEmptyStyle().Render(strings.Repeat("\u2591", barWidth-filled))390-391- frac := xpFractionStyle().Render(fmt.Sprintf(" %d/%d", xpIntoLevel, xpNeeded))392-393- return statLabelStyle().Foreground(theme.Current().FG).Render("XP ") + bar + frac394-}395-396-// RenderBanner renders the profile summary banner.397-func RenderBanner(b ProfileBanner, width int) string {398- if width < 20 {399- width = 40400- }401-402- ruleStr := bannerRuleStyle().Render(403- "\u2500\u2500\u2500 Profile " + strings.Repeat("\u2500", max(width-12, 1)),404- )405-406- streakStr := fmt.Sprintf("%d days", b.DailyStreak)407- if b.DailyStreak == 1 {408- streakStr = "1 day"409- }410- streakSuffix := "( )"411- if b.CurrentDaily {412- streakSuffix = "(\u2714)"413- }414- streakStr += " " + streakSuffix415-416- col1 := bannerLabelStyle().Render("Profile Level: ") + bannerValueStyle().Render(fmt.Sprintf("%d", b.ProfileLevel))417- col2 := bannerLabelStyle().Render("Daily Streak: ") + bannerValueStyle().Render(streakStr)418- col3 := bannerLabelStyle().Render("Total Dailies: ") + bannerValueStyle().Render(fmt.Sprintf("%d", b.TotalDailies))419- col4 := bannerLabelStyle().Render("Weeklies Completed: ") +420- bannerValueStyle().Render(421- fmt.Sprintf("%d total (%2d/99 this week)", b.WeekliesCompleted, b.ThisWeekHighestIndex),422- )423-424- // Two-column layout: left side + gap + right side.425- gap := width - lipgloss.Width(col1) - lipgloss.Width(col2)426- if gap < 2 {427- gap = 2428- }429- row1 := col1 + strings.Repeat(" ", gap) + col2430-431- gap2 := width - lipgloss.Width(col3) - lipgloss.Width(col4)432- if gap2 < 2 {433- gap2 = 2434- }435- row2 := col3 + strings.Repeat(" ", gap2) + col4436-437- bottomRule := bannerRuleStyle().Render(strings.Repeat("\u2500", max(width, 1)))438-439- return strings.Join([]string{ruleStr, row1, row2, bottomRule}, "\n")440-}441-442-// RenderCardGrid renders just the card grid (no banner). This is the443-// content placed inside the scrollable viewport.444-func RenderCardGrid(cards []Card, width int) string {445- if len(cards) == 0 {446- return lipgloss.NewStyle().447- Foreground(theme.Current().TextDim).448- Render("No stats yet \u2014 play some puzzles!")449- }450-451- return renderCardRows(cards, cardColumnCount(width))452-}453-454-// RenderView renders the full stats view (banner + card grid) as a single455-// string. Used only by tests and the empty-state path.456-func RenderView(banner ProfileBanner, cards []Card, width int) string {457- if len(cards) == 0 {458- return lipgloss.NewStyle().459- Foreground(theme.Current().TextDim).460- Render("No stats yet \u2014 play some puzzles!")461- }462-463- bannerStr := RenderBanner(banner, width)464- cardGrid := RenderCardGrid(cards, width)465-466- return lipgloss.JoinVertical(lipgloss.Left,467- bannerStr,468- "",469- cardGrid,470- )471-}472-473-func cardColumnCount(width int) int {474- if width <= CardFullWidth {475- return 1476- }477- cols := (width + cardColumnGap) / (CardFullWidth + cardColumnGap)478- return max(cols, 1)479-}480-481-func cardGridWidth(cols int) int {482- if cols < 1 {483- cols = 1484- }485- return cols*CardFullWidth + (cols-1)*cardColumnGap486-}487-488-func renderCardRows(cards []Card, cols int) string {489- if cols < 1 {490- cols = 1491- }492-493- palette := theme.Current().ThemeColors()494- rendered := make([]string, len(cards))495- for i, c := range cards {496- titleColor := palette[i%len(palette)]497- rendered[i] = RenderCard(c, titleColor)498- }499-500- rows := make([]string, 0, (len(rendered)+cols-1)/cols)501- for i := 0; i < len(rendered); i += cols {502- end := min(i+cols, len(rendered))503- row := rendered[i]504- for j := i + 1; j < end; j++ {505- row = lipgloss.JoinHorizontal(lipgloss.Top, row, strings.Repeat(" ", cardColumnGap), rendered[j])506- }507- rows = append(rows, row)508- }509-510- return strings.Join(rows, strings.Repeat("\n", cardRowGap+1))511-}
M stats/stats_test.go +47 -415
@@ -1,452 +1,84 @@
11 package stats22 33 import (4- "strings"54 "testing"65 "time"76 7+ "github.com/FelineStateMachine/puzzletea/puzzle"88 "github.com/FelineStateMachine/puzzletea/store"9- "github.com/charmbracelet/x/ansi"109 )1110 12-// --- ModeXP map (P0) ---13-14-func TestModeXPMap(t *testing.T) {15- // InitModeXP is called via TestMain or init in the main package.16- // For the stats package tests, we set up a known map directly.17- origXP := ModeXP18- defer func() { ModeXP = origXP }()19-20- ModeXP = map[ModeKey]int{21- {"Sudoku", "Easy"}: 1,22- {"Sudoku", "Medium"}: 3,23- {"Sudoku", "Hard"}: 5,24- }25-26- t.Run("all modes have positive XP", func(t *testing.T) {27- if len(ModeXP) == 0 {28- t.Fatal("ModeXP map is empty")29- }30- for k, v := range ModeXP {31- if v <= 0 {32- t.Errorf("ModeXP[%q/%q] = %d, want > 0", k.GameType, k.Mode, v)33- }34- }35- })36-}37-38-// --- LevelFromXP (P0) ---39-40-func TestLevelFromXP(t *testing.T) {41- tests := []struct {42- name string43- xp int44- want int45- }{46- {name: "zero XP", xp: 0, want: 0},47- {name: "negative XP", xp: -5, want: 0},48- {name: "1 XP", xp: 1, want: 0},49- {name: "4 XP", xp: 4, want: 0},50- {name: "5 XP = level 1", xp: 5, want: 1},51- {name: "15 XP still level 1", xp: 15, want: 1},52- {name: "16 XP = level 2", xp: 16, want: 2},53- {name: "28 XP still level 2", xp: 28, want: 2},54- {name: "29 XP = level 3", xp: 29, want: 3},55- {name: "45 XP still level 3", xp: 45, want: 3},56- {name: "46 XP = level 4", xp: 46, want: 4},57- {name: "65 XP still level 4", xp: 65, want: 4},58- {name: "66 XP = level 5", xp: 66, want: 5},59- }60- for _, tt := range tests {61- t.Run(tt.name, func(t *testing.T) {62- got := LevelFromXP(tt.xp)63- if got != tt.want {64- t.Errorf("LevelFromXP(%d) = %d, want %d", tt.xp, got, tt.want)65- }66- })11+func TestWeightsFromDefinitions(t *testing.T) {12+ weights := WeightsFromDefinitions([]puzzle.Definition{{13+ Name: "Sudoku",14+ Modes: []puzzle.ModeDef{15+ {Title: "Easy"},16+ {Title: "Medium"},17+ {Title: "Hard"},18+ },19+ }})20+ if len(weights) != 3 {21+ t.Fatalf("len(weights) = %d, want 3", len(weights))6722 }6823 }6924 70-// --- XPForLevel (P0) ---71-72-func TestXPForLevel(t *testing.T) {73- tests := []struct {74- name string75- level int76- want int77- }{78- {name: "level 0", level: 0, want: 0},79- {name: "level 1", level: 1, want: 5},80- {name: "level 2", level: 2, want: 16},81- {name: "level 3", level: 3, want: 29},82- {name: "level 4", level: 4, want: 46},83- {name: "level 5", level: 5, want: 66},25+func TestLevelFromXPAndXPForLevel(t *testing.T) {26+ if got := LevelFromXP(0); got != 0 {27+ t.Fatalf("LevelFromXP(0) = %d, want 0", got)8428 }85- for _, tt := range tests {86- t.Run(tt.name, func(t *testing.T) {87- got := XPForLevel(tt.level)88- if got != tt.want {89- t.Errorf("XPForLevel(%d) = %d, want %d", tt.level, got, tt.want)90- }91- })29+ if got := XPForLevel(3); got != 29 {30+ t.Fatalf("XPForLevel(3) = %d, want 29", got)9231 }9332 }9433 95-// --- Level/XP round-trip consistency (P0) ---96-97-func TestLevelXPRoundTrip(t *testing.T) {98- // For each level, XPForLevel should be the threshold that LevelFromXP99- // returns that level. One XP less should return level-1.100- for lvl := 1; lvl <= 30; lvl++ {101- threshold := XPForLevel(lvl)102- if LevelFromXP(threshold) != lvl {103- t.Errorf("LevelFromXP(XPForLevel(%d)=%d) = %d, want %d",104- lvl, threshold, LevelFromXP(threshold), lvl)105- }106- if threshold > 0 && LevelFromXP(threshold-1) != lvl-1 {107- t.Errorf("LevelFromXP(%d-1=%d) = %d, want %d",108- threshold, threshold-1, LevelFromXP(threshold-1), lvl-1)109- }110- }111-}112-113-// --- ComputeDailyStreak (P0) ---114-11534 func TestComputeDailyStreak(t *testing.T) {116- // Helper: date at midnight local time.117- d := func(year int, month time.Month, day int) time.Time {118- return time.Date(year, month, day, 0, 0, 0, 0, time.Local)119- }12035 now := time.Date(2026, time.February, 16, 15, 30, 0, 0, time.Local)121-122- tests := []struct {123- name string124- now time.Time125- dates []time.Time126- want int127- }{128- {129- name: "empty dates",130- now: now,131- dates: nil,132- want: 0,133- },134- {135- name: "streak of 1 today",136- now: now,137- dates: []time.Time{d(2026, 2, 16)},138- want: 1,139- },140- {141- name: "streak of 1 yesterday",142- now: now,143- dates: []time.Time{d(2026, 2, 15)},144- want: 1,145- },146- {147- name: "streak broken two days ago",148- now: now,149- dates: []time.Time{d(2026, 2, 14)},150- want: 0,151- },152- {153- name: "3-day streak ending today",154- now: now,155- dates: []time.Time{156- d(2026, 2, 16),157- d(2026, 2, 15),158- d(2026, 2, 14),159- },160- want: 3,161- },162- {163- name: "3-day streak ending yesterday",164- now: now,165- dates: []time.Time{166- d(2026, 2, 15),167- d(2026, 2, 14),168- d(2026, 2, 13),169- },170- want: 3,171- },172- {173- name: "gap breaks streak",174- now: now,175- dates: []time.Time{176- d(2026, 2, 16),177- d(2026, 2, 15),178- // gap: Feb 14 missing179- d(2026, 2, 13),180- d(2026, 2, 12),181- },182- want: 2,183- },36+ dates := []time.Time{37+ time.Date(2026, time.February, 16, 0, 0, 0, 0, time.Local),38+ time.Date(2026, time.February, 15, 0, 0, 0, 0, time.Local),39+ time.Date(2026, time.February, 14, 0, 0, 0, 0, time.Local),18440 }185- for _, tt := range tests {186- t.Run(tt.name, func(t *testing.T) {187- got := ComputeDailyStreak(tt.dates, tt.now)188- if got != tt.want {189- t.Errorf("ComputeDailyStreak() = %d, want %d", got, tt.want)190- }191- })41+ if got := ComputeDailyStreak(dates, now); got != 3 {42+ t.Fatalf("ComputeDailyStreak() = %d, want 3", got)19243 }19344 }19445 195-// --- ComputeCategoryXP (P1) ---196-19746 func TestComputeCategoryXP(t *testing.T) {198- // Override ModeXP for test.199- origXP := ModeXP200- defer func() { ModeXP = origXP }()201-202- ModeXP = map[ModeKey]int{203- {"Sudoku", "Easy"}: 1,204- {"Sudoku", "Medium"}: 3,205- {"Sudoku", "Hard"}: 5,206- }207-208- tests := []struct {209- name string210- gameType string211- modeStats []store.ModeStat212- want int213- }{214- {215- name: "no stats",216- gameType: "Sudoku",217- modeStats: nil,218- want: 0,219- },220- {221- name: "normal victories only",222- gameType: "Sudoku",223- modeStats: []store.ModeStat{224- {GameType: "Sudoku", Mode: "Easy", Victories: 5, DailyVictories: 0},225- {GameType: "Sudoku", Mode: "Hard", Victories: 2, DailyVictories: 0},226- },227- want: 5*1 + 2*5, // 15228- },229- {230- name: "daily 2x multiplier",231- gameType: "Sudoku",232- modeStats: []store.ModeStat{233- {GameType: "Sudoku", Mode: "Easy", Victories: 3, DailyVictories: 1},234- },235- // 2 normal * 1 + 1 daily * 1 * 2 = 4236- want: 4,237- },238- {239- name: "ignores other game types",240- gameType: "Sudoku",241- modeStats: []store.ModeStat{242- {GameType: "Sudoku", Mode: "Easy", Victories: 1, DailyVictories: 0},243- {GameType: "Nonogram", Mode: "Mini", Victories: 10, DailyVictories: 0},244- },245- want: 1,246- },247- {248- name: "weekly bonus xp added on top",249- gameType: "Sudoku",250- modeStats: []store.ModeStat{251- {GameType: "Sudoku", Mode: "Easy", Victories: 2, WeeklyBonusXP: 5},252- },253- want: 7,254- },255- {256- name: "unknown mode defaults to 1 XP",257- gameType: "Sudoku",258- modeStats: []store.ModeStat{259- {GameType: "Sudoku", Mode: "Unknown", Victories: 3, DailyVictories: 0},260- },261- want: 3,262- },263- }264- for _, tt := range tests {265- t.Run(tt.name, func(t *testing.T) {266- got := ComputeCategoryXP(tt.gameType, tt.modeStats)267- if got != tt.want {268- t.Errorf("ComputeCategoryXP(%q) = %d, want %d", tt.gameType, got, tt.want)269- }270- })271- }272-}273-274-// --- BuildCards (P1) ---275-276-func TestBuildCards(t *testing.T) {277- origXP := ModeXP278- defer func() { ModeXP = origXP }()279-280- ModeXP = map[ModeKey]int{47+ weights := Weights{28148 {"Sudoku", "Easy"}: 2,28249 {"Sudoku", "Hard"}: 8,28350 }284-285- catStats := []store.CategoryStat{286- {287- GameType: "Sudoku",288- TotalAttempts: 10,289- TotalVictories: 7,290- TotalAbandoned: 1,291- TimesDaily: 3,292- DailyVictories: 2,293- PreferredMode: "Easy",294- },295- }29651 modeStats := []store.ModeStat{29752 {GameType: "Sudoku", Mode: "Easy", Victories: 5, DailyVictories: 2},298- {GameType: "Sudoku", Mode: "Hard", Victories: 2, DailyVictories: 0},299- }300-301- cards := BuildCards(catStats, modeStats)302- if len(cards) != 1 {303- t.Fatalf("expected 1 card, got %d", len(cards))304- }305-306- c := cards[0]307- if c.GameType != "Sudoku" {308- t.Errorf("GameType = %q, want %q", c.GameType, "Sudoku")309- }310- if c.Victories != 7 {311- t.Errorf("Victories = %d, want 7", c.Victories)312- }313- if c.Attempts != 10 {314- t.Errorf("Attempts = %d, want 10", c.Attempts)315- }316- if c.DailyPlayed != 3 {317- t.Errorf("DailyPlayed = %d, want 3", c.DailyPlayed)318- }319- if c.PreferredMode != "Easy" {320- t.Errorf("PreferredMode = %q, want %q", c.PreferredMode, "Easy")321- }322-323- // XP: Easy: (5-2)*2 + 2*2*2 = 6+8 = 14; Hard: 2*8 = 16; total = 30324- if c.CurrentXP != 30 {325- t.Errorf("CurrentXP = %d, want 30", c.CurrentXP)326- }327-}328-329-// --- BuildCards empty state (P2) ---330-331-func TestBuildCardsEmpty(t *testing.T) {332- cards := BuildCards(nil, nil)333- if len(cards) != 0 {334- t.Errorf("expected 0 cards for nil stats, got %d", len(cards))335- }336-}337-338-// --- RenderView empty state (P2) ---339-340-func TestRenderViewEmpty(t *testing.T) {341- result := RenderView(ProfileBanner{}, nil, 80)342- if result == "" {343- t.Error("expected non-empty output for empty state")344- }345-}346-347-func TestRenderBannerAppendsDailyStatusToStreak(t *testing.T) {348- withCurrent := ansi.Strip(RenderBanner(ProfileBanner{349- ProfileLevel: 3,350- DailyStreak: 2,351- TotalDailies: 5,352- CurrentDaily: true,353- WeekliesCompleted: 4,354- ThisWeekHighestIndex: 17,355- }, 80))356- if !strings.Contains(withCurrent, "Daily Streak: 2 days (✔)") {357- t.Fatalf("expected current daily marker in banner:\n%s", withCurrent)358- }359- if !strings.Contains(withCurrent, "Weeklies Completed: 4 total (17/99 this week)") {360- t.Fatalf("expected weekly completion summary in banner:\n%s", withCurrent)361- }362-363- withoutCurrent := ansi.Strip(RenderBanner(ProfileBanner{364- ProfileLevel: 3,365- DailyStreak: 2,366- TotalDailies: 5,367- CurrentDaily: false,368- WeekliesCompleted: 0,369- ThisWeekHighestIndex: 0,370- }, 80))371- if !strings.Contains(withoutCurrent, "Daily Streak: 2 days ( )") {372- t.Fatalf("expected empty daily marker in banner:\n%s", withoutCurrent)53+ {GameType: "Sudoku", Mode: "Hard", Victories: 2},37354 }374- if !strings.Contains(withoutCurrent, "Weeklies Completed: 0 total ( 0/99 this week)") {375- t.Fatalf("expected weekly completion summary in banner:\n%s", withoutCurrent)55+ if got := ComputeCategoryXP(weights, "Sudoku", modeStats); got != 30 {56+ t.Fatalf("ComputeCategoryXP() = %d, want 30", got)37657 }37758 }37859 379-// --- Card grid layout (P1) ---380-381-func TestCardColumnCount(t *testing.T) {382- tests := []struct {383- name string384- width int385- want int386- }{387- {name: "narrow", width: 20, want: 1},388- {name: "single card", width: CardFullWidth, want: 1},389- {name: "two cards", width: cardGridWidth(2), want: 2},390- {name: "three cards", width: cardGridWidth(3), want: 3},391- }392-393- for _, tt := range tests {394- t.Run(tt.name, func(t *testing.T) {395- if got := cardColumnCount(tt.width); got != tt.want {396- t.Fatalf("cardColumnCount(%d) = %d, want %d", tt.width, got, tt.want)397- }398- })399- }400-}401-402-func TestRenderCardGridWrapsRowsByWidth(t *testing.T) {403- cards := testCards(5)404- width := cardGridWidth(3)405- rendered := ansi.Strip(RenderCardGrid(cards, width))406- lines := strings.Split(rendered, "\n")407- if len(lines) <= CardHeight {408- t.Fatalf("expected wrapped second row, got %d lines", len(lines))409- }410- if got := strings.Count(lines[0], "╭"); got != 3 {411- t.Fatalf("first row cards = %d, want 3", got)412- }413- secondRowStart := CardHeight + cardRowGap414- for i := CardHeight; i < secondRowStart; i++ {415- if lines[i] != "" {416- t.Fatalf("row gap line %d = %q, want blank", i, lines[i])417- }418- }419- if got := strings.Count(lines[secondRowStart], "╭"); got != 2 {420- t.Fatalf("second row cards = %d, want 2", got)60+func TestBuildCardsAndProfile(t *testing.T) {61+ weights := Weights{62+ {"Sudoku", "Easy"}: 2,63+ {"Sudoku", "Hard"}: 8,42164 }422- if got := maxRenderedWidth(rendered); got > width {423- t.Fatalf("rendered width = %d, want <= %d", got, width)65+ catStats := []store.CategoryStat{{66+ GameType: "Sudoku",67+ TotalAttempts: 10,68+ TotalVictories: 7,69+ TimesDaily: 3,70+ PreferredMode: "Easy",71+ }}72+ modeStats := []store.ModeStat{73+ {GameType: "Sudoku", Mode: "Easy", Victories: 5, DailyVictories: 2},74+ {GameType: "Sudoku", Mode: "Hard", Victories: 2},42475 }425-}426-427-func testCards(n int) []Card {428- cards := make([]Card, n)429- for i := range n {430- cards[i] = Card{431- GameType: "Test",432- Level: 1,433- PreferredMode: "Easy",434- Victories: 1,435- Attempts: 1,436- DailyPlayed: 1,437- CurrentXP: 1,438- NextLevelXP: 5,439- }76+ cards := BuildCards(weights, catStats, modeStats)77+ if len(cards) != 1 || cards[0].CurrentXP != 30 {78+ t.Fatalf("BuildCards() = %#v", cards)44079 }441- return cards442-}443-444-func maxRenderedWidth(s string) int {445- maxW := 0446- for _, line := range strings.Split(s, "\n") {447- if w := ansi.StringWidth(line); w > maxW {448- maxW = w449- }80+ profile := BuildProfileBanner(catStats, modeStats, weights, nil, false, 4, 17)81+ if profile.WeekliesCompleted != 4 || profile.ThisWeekHighestIndex != 17 {82+ t.Fatalf("BuildProfileBanner() = %#v", profile)45083 }451- return maxW45284 }
M store/game_record.go +14 -0
A store/metadata.go +93 -0
@@ -0,0 +1,93 @@
1+package store2+3+import (4+ "regexp"5+ "strconv"6+ "strings"7+ "time"8+9+ "github.com/FelineStateMachine/puzzletea/puzzle"10+)11+12+var weeklyNamePattern = regexp.MustCompile(`^Week (\d{2})-(\d{4}) - #(\d{2})$`)13+14+func RunKindForName(name string) RunKind {15+ switch {16+ case RunDateForName(name) != nil:17+ return RunKindDaily18+ case isWeeklyName(name):19+ return RunKindWeekly20+ case SeedTextForName(name) != "":21+ return RunKindSeeded22+ default:23+ return RunKindNormal24+ }25+}26+27+func RunDateForName(name string) *time.Time {28+ if !strings.HasPrefix(name, "Daily ") {29+ return nil30+ }31+ label, _, found := strings.Cut(strings.TrimPrefix(name, "Daily "), " - ")32+ if !found {33+ return nil34+ }35+ t, err := time.ParseInLocation("Jan _2 06", label, time.Local)36+ if err != nil {37+ return nil38+ }39+ day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.Local)40+ return &day41+}42+43+func WeeklyIdentityForName(name string) (year, week, index int, ok bool) {44+ matches := weeklyNamePattern.FindStringSubmatch(name)45+ if len(matches) != 4 {46+ return 0, 0, 0, false47+ }48+49+ week, err := strconv.Atoi(matches[1])50+ if err != nil {51+ return 0, 0, 0, false52+ }53+ year, err = strconv.Atoi(matches[2])54+ if err != nil {55+ return 0, 0, 0, false56+ }57+ index, err = strconv.Atoi(matches[3])58+ if err != nil {59+ return 0, 0, 0, false60+ }61+ return year, week, index, true62+}63+64+func SeedTextForName(name string) string {65+ if strings.HasPrefix(name, "Daily ") || isWeeklyName(name) {66+ return ""67+ }68+ seedPart, _, found := strings.Cut(name, " - ")69+ if !found {70+ return ""71+ }72+ seedPart = strings.TrimSpace(seedPart)73+ if seedPart == "" {74+ return ""75+ }76+ if before, _, hasGame := strings.Cut(seedPart, " ["); hasGame {77+ return strings.TrimSpace(before)78+ }79+ return seedPart80+}81+82+func CanonicalGameID(gameType string) string {83+ return string(puzzle.CanonicalGameID(gameType))84+}85+86+func CanonicalModeID(mode string) string {87+ return string(puzzle.CanonicalModeID(mode))88+}89+90+func isWeeklyName(name string) bool {91+ _, _, _, ok := WeeklyIdentityForName(name)92+ return ok93+}
A store/migrate.go +142 -0
@@ -0,0 +1,142 @@
1+package store2+3+import (4+ "database/sql"5+ "fmt"6+)7+8+type gameRowMeta struct {9+ ID int6410+ Name string11+ GameType string12+ Mode string13+ GameID string14+ ModeID string15+ RunKind string16+}17+18+func ensureGameColumns(db *sql.DB) error {19+ columns := []struct {20+ name string21+ definition string22+ }{23+ {name: "game_id", definition: "TEXT NOT NULL DEFAULT ''"},24+ {name: "mode_id", definition: "TEXT NOT NULL DEFAULT ''"},25+ {name: "run_kind", definition: "TEXT NOT NULL DEFAULT 'normal'"},26+ {name: "run_date", definition: "DATE"},27+ {name: "week_year", definition: "INTEGER"},28+ {name: "week_number", definition: "INTEGER"},29+ {name: "week_index", definition: "INTEGER"},30+ {name: "seed_text", definition: "TEXT"},31+ }32+33+ existing, err := tableColumns(db, "games")34+ if err != nil {35+ return err36+ }37+ for _, column := range columns {38+ if existing[column.name] {39+ continue40+ }41+ if _, err := db.Exec(fmt.Sprintf("ALTER TABLE games ADD COLUMN %s %s", column.name, column.definition)); err != nil {42+ return fmt.Errorf("adding games.%s: %w", column.name, err)43+ }44+ }45+ return nil46+}47+48+func tableColumns(db *sql.DB, table string) (map[string]bool, error) {49+ rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))50+ if err != nil {51+ return nil, fmt.Errorf("querying %s columns: %w", table, err)52+ }53+ defer rows.Close()54+55+ columns := map[string]bool{}56+ for rows.Next() {57+ var (58+ cid int59+ name string60+ typ string61+ notNull int62+ defaultV any63+ primaryKey int64+ )65+ if err := rows.Scan(&cid, &name, &typ, &notNull, &defaultV, &primaryKey); err != nil {66+ return nil, fmt.Errorf("scanning %s column: %w", table, err)67+ }68+ columns[name] = true69+ }70+ return columns, rows.Err()71+}72+73+func backfillGameMetadata(db *sql.DB) error {74+ rows, err := db.Query(`SELECT id, name, game_type, mode, game_id, mode_id, run_kind FROM games`)75+ if err != nil {76+ return fmt.Errorf("querying game metadata for backfill: %w", err)77+ }78+ defer rows.Close()79+80+ var metas []gameRowMeta81+ for rows.Next() {82+ var meta gameRowMeta83+ if err := rows.Scan(&meta.ID, &meta.Name, &meta.GameType, &meta.Mode, &meta.GameID, &meta.ModeID, &meta.RunKind); err != nil {84+ return fmt.Errorf("scanning game metadata for backfill: %w", err)85+ }86+ metas = append(metas, meta)87+ }88+ if err := rows.Err(); err != nil {89+ return fmt.Errorf("reading game metadata for backfill: %w", err)90+ }91+92+ for _, meta := range metas {93+ gameID := meta.GameID94+ if gameID == "" {95+ gameID = CanonicalGameID(meta.GameType)96+ }97+ modeID := meta.ModeID98+ if modeID == "" {99+ modeID = CanonicalModeID(meta.Mode)100+ }101+ runKind := RunKind(meta.RunKind)102+ if runKind == "" || runKind == RunKindNormal {103+ runKind = RunKindForName(meta.Name)104+ }105+ runDate := RunDateForName(meta.Name)106+ seedText := SeedTextForName(meta.Name)107+ year, week, index, ok := WeeklyIdentityForName(meta.Name)108+ if _, err := db.Exec(109+ `UPDATE games110+ SET game_id = ?, mode_id = ?, run_kind = ?, run_date = ?,111+ week_year = ?, week_number = ?, week_index = ?, seed_text = ?112+ WHERE id = ?`,113+ gameID, modeID, string(runKind), runDate,114+ nullableIntIf(ok, year), nullableIntIf(ok, week), nullableIntIf(ok, index),115+ nullableString(seedText), meta.ID,116+ ); err != nil {117+ return fmt.Errorf("backfilling game metadata for %d: %w", meta.ID, err)118+ }119+ }120+ return nil121+}122+123+func nullableInt(value int) any {124+ if value == 0 {125+ return nil126+ }127+ return value128+}129+130+func nullableIntIf(ok bool, value int) any {131+ if !ok || value == 0 {132+ return nil133+ }134+ return value135+}136+137+func nullableString(value string) any {138+ if value == "" {139+ return nil140+ }141+ return value142+}
M store/stats.go +49 -67
@@ -4,8 +4,6 @@ import (
44 "database/sql"55 "fmt"66 "time"7-8- "github.com/FelineStateMachine/puzzletea/weekly"97 )108 119 // CategoryStat holds aggregate statistics for a single game category.
@@ -96,12 +94,13 @@ func (s *Store) GetModeStats() ([]ModeStat, error) {
9694 // ordered most recent first. Dates are in local time with time set to midnight.9795 func (s *Store) GetDailyStreakDates() ([]time.Time, error) {9896 rows, err := s.db.Query(99- `SELECT DISTINCT DATE(REPLACE(SUBSTR(completed_at, 1, 19), 'T', ' '), 'localtime') AS d97+ `SELECT DISTINCT run_date10098 FROM games101- WHERE name LIKE 'Daily %'99+ WHERE run_kind = ?102100 AND status = 'completed'103- AND completed_at IS NOT NULL104- ORDER BY d DESC`,101+ AND run_date IS NOT NULL102+ ORDER BY run_date DESC`,103+ string(RunKindDaily),105104 )106105 if err != nil {107106 return nil, fmt.Errorf("querying daily streak dates: %w", err)
@@ -110,15 +109,15 @@ func (s *Store) GetDailyStreakDates() ([]time.Time, error) {
110109 111110 var dates []time.Time112111 for rows.Next() {113- var dateStr string114- if err := rows.Scan(&dateStr); err != nil {112+ var dateValue sql.NullTime113+ if err := rows.Scan(&dateValue); err != nil {115114 return nil, fmt.Errorf("scanning daily streak date: %w", err)116115 }117- t, err := time.ParseInLocation("2006-01-02", dateStr, time.Local)118- if err != nil {119- return nil, fmt.Errorf("parsing date %q: %w", dateStr, err)116+ if !dateValue.Valid {117+ continue120118 }121- dates = append(dates, t)119+ t := dateValue.Time.In(time.Local)120+ dates = append(dates, time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.Local))122121 }123122 return dates, rows.Err()124123 }
@@ -126,76 +125,62 @@ func (s *Store) GetDailyStreakDates() ([]time.Time, error) {
126125 // GetCompletedWeeklyGauntlets returns the number of ISO weeks whose highest127126 // completed weekly index is 99.128127 func (s *Store) GetCompletedWeeklyGauntlets() (int, error) {129- highestByWeek, err := s.highestCompletedWeeklyIndexByWeek()128+ rows, err := s.db.Query(129+ `SELECT week_year, week_number, MAX(week_index)130+ FROM games131+ WHERE status = 'completed'132+ AND run_kind = ?133+ AND week_year IS NOT NULL134+ AND week_number IS NOT NULL135+ GROUP BY week_year, week_number`,136+ string(RunKindWeekly),137+ )130138 if err != nil {131- return 0, err139+ return 0, fmt.Errorf("querying completed weekly gauntlets: %w", err)132140 }133-141+ defer rows.Close()134142 completed := 0135- for _, highest := range highestByWeek {143+ for rows.Next() {144+ var year, week, highest int145+ if err := rows.Scan(&year, &week, &highest); err != nil {146+ return 0, fmt.Errorf("scanning weekly gauntlet row: %w", err)147+ }136148 if highest >= 99 {137149 completed++138150 }139151 }140- return completed, nil152+ return completed, rows.Err()141153 }142154 143155 // GetCurrentWeeklyHighestCompletedIndex returns the highest completed weekly144156 // index for the provided ISO week-year.145157 func (s *Store) GetCurrentWeeklyHighestCompletedIndex(year, week int) (int, error) {146- highestByWeek, err := s.highestCompletedWeeklyIndexByWeek()147- if err != nil {148- return 0, err149- }150- return highestByWeek[weeklyKey{year: year, week: week}], nil151-}152-153-type weeklyKey struct {154- year int155- week int156-}157-158-func (s *Store) highestCompletedWeeklyIndexByWeek() (map[weeklyKey]int, error) {159- rows, err := s.db.Query(160- `SELECT name158+ var highest sql.NullInt64159+ err := s.db.QueryRow(160+ `SELECT MAX(week_index)161161 FROM games162162 WHERE status = 'completed'163- AND name LIKE 'Week %'`,164- )163+ AND run_kind = ?164+ AND week_year = ?165+ AND week_number = ?`,166+ string(RunKindWeekly), year, week,167+ ).Scan(&highest)165168 if err != nil {166- return nil, fmt.Errorf("querying completed weekly names: %w", err)169+ return 0, fmt.Errorf("querying current weekly highest index: %w", err)167170 }168- defer rows.Close()169-170- highestByWeek := make(map[weeklyKey]int)171- for rows.Next() {172- var name string173- if err := rows.Scan(&name); err != nil {174- return nil, fmt.Errorf("scanning completed weekly name: %w", err)175- }176-177- info, ok := weekly.ParseName(name)178- if !ok {179- continue180- }181-182- key := weeklyKey{year: info.Year, week: info.Week}183- if info.Index > highestByWeek[key] {184- highestByWeek[key] = info.Index185- }186- }187- if err := rows.Err(); err != nil {188- return nil, fmt.Errorf("reading completed weekly names: %w", err)171+ if !highest.Valid {172+ return 0, nil189173 }190- return highestByWeek, nil174+ return int(highest.Int64), nil191175 }192176 193177 func (s *Store) applyWeeklyBonusXP(stats []ModeStat) ([]ModeStat, error) {194178 rows, err := s.db.Query(195- `SELECT name, game_type, mode179+ `SELECT week_index, game_type, mode196180 FROM games197181 WHERE status = 'completed'198- AND name LIKE 'Week %'`,182+ AND run_kind = ?`,183+ string(RunKindWeekly),199184 )200185 if err != nil {201186 return nil, fmt.Errorf("querying completed weekly games: %w", err)
@@ -213,18 +198,13 @@ func (s *Store) applyWeeklyBonusXP(stats []ModeStat) ([]ModeStat, error) {
213198 }214199 215200 for rows.Next() {216- var name string201+ var weekIndex int217202 var gameType string218203 var mode string219- if err := rows.Scan(&name, &gameType, &mode); err != nil {204+ if err := rows.Scan(&weekIndex, &gameType, &mode); err != nil {220205 return nil, fmt.Errorf("scanning completed weekly game: %w", err)221206 }222207 223- info, ok := weekly.ParseName(name)224- if !ok {225- continue226- }227-228208 k := key{gameType: gameType, mode: mode}229209 i, ok := indexByKey[k]230210 if !ok {
@@ -236,7 +216,9 @@ func (s *Store) applyWeeklyBonusXP(stats []ModeStat) ([]ModeStat, error) {
236216 i = len(stats) - 1237217 indexByKey[k] = i238218 }239- stats[i].WeeklyBonusXP += weekly.BonusXP(info.Index)219+ if weekIndex >= 10 {220+ stats[i].WeeklyBonusXP += weekIndex / 10221+ }240222 }241223 242224 if err := rows.Err(); err != nil {
M store/store.go +103 -22
@@ -9,7 +9,6 @@ import (
99 "path/filepath"1010 "time"1111 12- "github.com/FelineStateMachine/puzzletea/weekly"1312 _ "modernc.org/sqlite"1413 )1514
@@ -17,12 +16,21 @@ const createTableSQL = `
1716 CREATE TABLE IF NOT EXISTS games (1817 id INTEGER PRIMARY KEY AUTOINCREMENT,1918 name TEXT NOT NULL UNIQUE,19+ game_id TEXT NOT NULL DEFAULT '',2020 game_type TEXT NOT NULL,21+ mode_id TEXT NOT NULL DEFAULT '',2122 mode TEXT NOT NULL,2223 initial_state TEXT NOT NULL,2324 save_state TEXT,2425 status TEXT NOT NULL DEFAULT 'new'2526 CHECK(status IN ('new','in_progress','completed','abandoned')),27+ run_kind TEXT NOT NULL DEFAULT 'normal'28+ CHECK(run_kind IN ('normal','daily','weekly','seeded')),29+ run_date DATE,30+ week_year INTEGER,31+ week_number INTEGER,32+ week_index INTEGER,33+ seed_text TEXT,2634 created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,2735 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,2836 completed_at DATETIME
@@ -35,9 +43,9 @@ SELECT
3543 COUNT(*) AS total_attempts,3644 COUNT(*) FILTER (WHERE status = 'completed') AS total_victories,3745 COUNT(*) FILTER (WHERE status = 'abandoned') AS total_abandoned,38- COUNT(*) FILTER (WHERE name LIKE 'Daily %') AS times_daily,46+ COUNT(*) FILTER (WHERE run_kind = 'daily') AS times_daily,3947 COUNT(*) FILTER (WHERE status = 'completed'40- AND name LIKE 'Daily %') AS daily_victories,48+ AND run_kind = 'daily') AS daily_victories,4149 (SELECT mode FROM games g24250 WHERE g2.game_type = games.game_type4351 AND g2.status = 'completed'
@@ -54,7 +62,7 @@ SELECT
5462 mode,5563 COUNT(*) FILTER (WHERE status = 'completed') AS victories,5664 COUNT(*) FILTER (WHERE status = 'completed'57- AND name LIKE 'Daily %') AS daily_victories65+ AND run_kind = 'daily') AS daily_victories5866 FROM games5967 GROUP BY game_type, mode;`6068
@@ -62,7 +70,8 @@ type Store struct {
6270 db *sql.DB6371 }6472 65-const gameSelectColumns = `id, name, game_type, mode, initial_state, save_state, status,73+const gameSelectColumns = `id, name, game_id, game_type, mode_id, mode, initial_state, save_state,74+ status, run_kind, run_date, week_year, week_number, week_index, seed_text,6675 created_at, updated_at, completed_at`6776 6877 type rowScanner interface {
@@ -71,14 +80,36 @@ type rowScanner interface {
7180 7281 func scanGameRecord(scanner rowScanner) (GameRecord, error) {7382 var g GameRecord83+ var runDate sql.NullTime84+ var weekYear sql.NullInt6485+ var weekNumber sql.NullInt6486+ var weekIndex sql.NullInt6487+ var seedText sql.NullString7488 var completedAt sql.NullTime7589 if err := scanner.Scan(76- &g.ID, &g.Name, &g.GameType, &g.Mode,90+ &g.ID, &g.Name, &g.GameID, &g.GameType, &g.ModeID, &g.Mode,7791 &g.InitialState, &g.SaveState, &g.Status,92+ &g.RunKind, &runDate, &weekYear, &weekNumber, &weekIndex, &seedText,7893 &g.CreatedAt, &g.UpdatedAt, &completedAt,7994 ); err != nil {8095 return GameRecord{}, err8196 }97+ if runDate.Valid {98+ t := runDate.Time99+ g.RunDate = &t100+ }101+ if weekYear.Valid {102+ g.WeekYear = int(weekYear.Int64)103+ }104+ if weekNumber.Valid {105+ g.WeekNumber = int(weekNumber.Int64)106+ }107+ if weekIndex.Valid {108+ g.WeekIndex = int(weekIndex.Int64)109+ }110+ if seedText.Valid {111+ g.SeedText = seedText.String112+ }82113 if completedAt.Valid {83114 g.CompletedAt = &completedAt.Time84115 }
@@ -137,6 +168,24 @@ func Open(dbPath string) (*Store, error) {
137168 return nil, fmt.Errorf("creating table: %w", err)138169 }139170 171+ if err := ensureGameColumns(db); err != nil {172+ db.Close()173+ return nil, err174+ }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+140189 if _, err := db.Exec(createCategoryStatsViewSQL); err != nil {141190 db.Close()142191 return nil, fmt.Errorf("creating category_stats view: %w", err)
@@ -152,10 +201,37 @@ func Open(dbPath string) (*Store, error) {
152201 153202 // CreateGame inserts a new game record and sets rec.ID.154203 func (s *Store) CreateGame(rec *GameRecord) error {204+ if rec.GameID == "" {205+ rec.GameID = CanonicalGameID(rec.GameType)206+ }207+ if rec.ModeID == "" {208+ rec.ModeID = CanonicalModeID(rec.Mode)209+ }210+ if rec.RunKind == "" {211+ rec.RunKind = RunKindForName(rec.Name)212+ }213+ if rec.RunDate == nil {214+ rec.RunDate = RunDateForName(rec.Name)215+ }216+ if rec.SeedText == "" {217+ rec.SeedText = SeedTextForName(rec.Name)218+ }219+ if rec.WeekYear == 0 || rec.WeekNumber == 0 || rec.WeekIndex == 0 {220+ if year, week, index, ok := WeeklyIdentityForName(rec.Name); ok {221+ rec.WeekYear = year222+ rec.WeekNumber = week223+ rec.WeekIndex = index224+ }225+ }226+155227 result, err := s.db.Exec(156- `INSERT INTO games (name, game_type, mode, initial_state, save_state, status)157- VALUES (?, ?, ?, ?, ?, ?)`,158- rec.Name, rec.GameType, rec.Mode, rec.InitialState, rec.SaveState, rec.Status,228+ `INSERT INTO games229+ (name, game_id, game_type, mode_id, mode, initial_state, save_state, status,230+ run_kind, run_date, week_year, week_number, week_index, seed_text)231+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,232+ rec.Name, rec.GameID, rec.GameType, rec.ModeID, rec.Mode,233+ rec.InitialState, rec.SaveState, rec.Status, string(rec.RunKind), rec.RunDate,234+ nullableInt(rec.WeekYear), nullableInt(rec.WeekNumber), nullableInt(rec.WeekIndex), nullableString(rec.SeedText),159235 )160236 if err != nil {161237 return fmt.Errorf("inserting game: %w", err)
@@ -239,7 +315,19 @@ func (s *Store) GetDeterministicGame(name string) (*GameRecord, error) {
239315 240316 // GetWeeklyGame looks up a weekly game by canonical week and index.241317 func (s *Store) GetWeeklyGame(year, weekNumber, index int) (*GameRecord, error) {242- return s.GetDeterministicGame(weekly.Name(year, weekNumber, index))318+ g, err := s.getGameByQuery(319+ fmt.Sprintf(`SELECT %s320+ FROM games321+ WHERE run_kind = ?322+ AND week_year = ?323+ AND week_number = ?324+ AND week_index = ?`, gameSelectColumns),325+ string(RunKindWeekly), year, weekNumber, index,326+ )327+ if err != nil {328+ return nil, fmt.Errorf("querying weekly game: %w", err)329+ }330+ return g, nil243331 }244332 245333 // ListWeeklyGames returns all records matching the canonical weekly prefix for
@@ -248,23 +336,16 @@ func (s *Store) ListWeeklyGames(year, weekNumber int) ([]GameRecord, error) {
248336 games, err := s.listGamesByQuery(249337 fmt.Sprintf(`SELECT %s250338 FROM games251- WHERE name LIKE ?339+ WHERE run_kind = ?340+ AND week_year = ?341+ AND week_number = ?252342 ORDER BY name DESC`, gameSelectColumns),253- weekly.Prefix(year, weekNumber)+"%",343+ string(RunKindWeekly), year, weekNumber,254344 )255345 if err != nil {256346 return nil, fmt.Errorf("listing weekly games: %w", err)257347 }258-259- filtered := make([]GameRecord, 0, len(games))260- for _, g := range games {261- info, ok := weekly.ParseName(g.Name)262- if !ok || info.Year != year || info.Week != weekNumber {263- continue264- }265- filtered = append(filtered, g)266- }267- return filtered, nil348+ return games, nil268349 }269350 270351 // GetGameByName looks up a single non-abandoned game by its unique name.
M sudoku/PrintAdapter.go +1 -2

Not shown: this page already shows 6000 lines of diff.

M sudoku/style.go +1 -25

Not shown: this page already shows 6000 lines of diff.

M sudokurgb/PrintAdapter.go +1 -2

Not shown: this page already shows 6000 lines of diff.

A sudokurgb/dynamic_board_test.go +145 -0

Not shown: this page already shows 6000 lines of diff.

M sudokurgb/style.go +34 -7

Not shown: this page already shows 6000 lines of diff.

M takuzu/Gamemode.go +1 -1

Not shown: this page already shows 6000 lines of diff.

M takuzu/PrintAdapter.go +1 -2

Not shown: this page already shows 6000 lines of diff.

M takuzu/style.go +11 -10

Not shown: this page already shows 6000 lines of diff.

M takuzu/takuzu_test.go +74 -0

Not shown: this page already shows 6000 lines of diff.

M takuzuplus/Gamemode.go +1 -1

Not shown: this page already shows 6000 lines of diff.

M takuzuplus/PrintAdapter.go +1 -2

Not shown: this page already shows 6000 lines of diff.

A ui/stats_view.go +180 -0

Not shown: this page already shows 6000 lines of diff.

M weekly/weekly.go +4 -4

Not shown: this page already shows 6000 lines of diff.

M wordsearch/Gamemode.go +1 -1

Not shown: this page already shows 6000 lines of diff.

M wordsearch/PrintAdapter.go +1 -2

Not shown: this page already shows 6000 lines of diff.