- commit
- 12dcb68efc27c71c7a0e260a73ef9a785e49509d
- parent
- 13be83c6cdf207ed0809f879492416edcc0e7cfa
- author
- Dami <dami.etoile@gmail.com>
@@ -2,13 +2,13 @@
22 33 A terminal-based puzzle game collection built with [Bubble Tea](https://github.com/charmbracelet/bubbletea).44 5-Eleven puzzle types, multiple difficulty modes, daily challenges, XP progression, 365 color themes, and an explicit game catalog for adding new games.5+Twelve puzzle types, multiple difficulty modes, daily challenges, XP progression, 365 color themes, and an explicit game catalog for adding new games.66 77 88 99 ## Features1010 11-- **11 puzzle games** -- Fillomino, Nonogram, Nurikabe, Ripple Effect, Sudoku, Shikaku, Word Search, Hashiwokakero, Hitori, Lights Out, Takuzu11+- **12 puzzle games** -- Fillomino, Nonogram, Nurikabe, Ripple Effect, Sudoku, Shikaku, Word Search, Hashiwokakero, Hitori, Lights Out, Takuzu, Takuzu+1212 - **Daily puzzles** -- A unique puzzle generated each day using deterministic seeding. Same date, same puzzle for everyone. Streak tracking rewards consecutive daily completions.1313 - **XP and leveling** -- Per-category levels based on victories. Harder modes yield more XP. Daily puzzles grant 2x XP.1414 - **Stats dashboard** -- Profile level, daily streak, victory counts, and XP progress bars per category.@@ -32,6 +32,7 @@ Eleven puzzle types, multiple difficulty modes, daily challenges, XP progression
3232 | **Hitori** | Shade cells to eliminate duplicates | 6 modes from 5x5 to 12x12 |3333 | **Lights Out** | Toggle lights to turn all off | Easy (3x3) to Extreme (9x9) |3434 | **Takuzu** | Fill grid with two symbols | 7 modes from 6x6 to 14x14 |35+| **Takuzu+** | Fill grid with symbols plus `=` and `x` relation clues | 7 modes from 6x6 to 14x14 |3536 3637 ## Install3738 @@ -157,7 +158,7 @@ puzzletea --continue amber-falcon
157158 158159 ### CLI Aliases159160 160-Several shorthand names are accepted for games: `polyomino`/`regions` for Fillomino, `hashi`/`bridges` for Hashiwokakero, `lights` for Lights Out, `islands`/`sea` for Nurikabe, `ripple` for Ripple Effect, `binairo`/`binary` for Takuzu, `words`/`ws` for Word Search, `rectangles` for Shikaku.161+Several shorthand names are accepted for games: `polyomino`/`regions` for Fillomino, `hashi`/`bridges` for Hashiwokakero, `lights` for Lights Out, `islands`/`sea` for Nurikabe, `ripple` for Ripple Effect, `binairo`/`binary` for Takuzu, `binario+` for Takuzu+, `words`/`ws` for Word Search, `rectangles` for Shikaku.161162 162163 ## Controls163164 @@ -55,3 +55,31 @@ func TestStatsViewportSizeReservesBannerHeight(t *testing.T) {
5555 t.Fatalf("stats height = %d, want %d", statsHeight, helpHeight-5)5656 }5757 }58+59+func TestGameSelectViewContentSizeIsStableAcrossCategories(t *testing.T) {60+ l := ui.InitCategoryList(GameCategories, "Select Category")61+ m := model{62+ state: gameSelectView,63+ width: 120,64+ height: 40,65+ nav: navigationState{66+ gameSelectList: l,67+ },68+ }69+ m = m.updateCategoryDetailViewport()70+71+ wantWidth := lipgloss.Width(m.gameSelectViewContent())72+ wantHeight := lipgloss.Height(m.gameSelectViewContent())73+74+ for i := range m.nav.gameSelectList.Items() {75+ m.nav.gameSelectList.Select(i)76+ m = m.updateCategoryDetailViewport()77+78+ if got := lipgloss.Width(m.gameSelectViewContent()); got != wantWidth {79+ t.Fatalf("selection %d width = %d, want %d", i, got, wantWidth)80+ }81+ if got := lipgloss.Height(m.gameSelectViewContent()); got != wantHeight {82+ t.Fatalf("selection %d height = %d, want %d", i, got, wantHeight)83+ }84+ }85+}@@ -72,12 +72,17 @@ type navigationState struct {
7272 playMenu ui.MainMenu7373 optionsMenu ui.MainMenu7474 gameSelectList list.Model75+ categoryDetail viewport.Model7576 modeSelectList list.Model7677 selectedCategory game.Category7778 selectedModeTitle string7879 continueTable table.Model7980 continueGames []store.GameRecord8081 seedInput textinput.Model82+ seedModeOptions []seedModeOption83+ seedModeIndex int84+ seedFocus seedInputFocus85+ lastSeedModeKey string8186 helpSelectList list.Model8287 }8388 @@ -132,6 +137,19 @@ type spawnRequest struct {
132137 returnState viewState133138 }134139 140+type seedInputFocus int141+142+const (143+ seedFocusText seedInputFocus = iota144+ seedFocusMode145+)146+147+type seedModeOption struct {148+ key string149+ label string150+ gameType string151+}152+135153 type model struct {136154 state viewState137155 @@ -0,0 +1,173 @@
1+package app2+3+import (4+ "strings"5+6+ "github.com/FelineStateMachine/puzzletea/catalog"7+ "github.com/FelineStateMachine/puzzletea/game"8+ "github.com/FelineStateMachine/puzzletea/ui"9+10+ "charm.land/bubbles/v2/textinput"11+ tea "charm.land/bubbletea/v2"12+ "charm.land/lipgloss/v2"13+)14+15+const randomSeedModeLabel = "Random"16+17+func buildSeedModeOptions(definitions []game.Definition) []seedModeOption {18+ options := []seedModeOption{{label: randomSeedModeLabel}}19+20+ for _, def := range definitions {21+ if !definitionHasSeededModes(def) {22+ continue23+ }24+25+ for _, item := range def.Modes {26+ if _, ok := item.(game.SeededSpawner); !ok {27+ continue28+ }29+30+ options = append(options, seedModeOption{31+ key: seedModeKey(def.Name, ""),32+ label: def.Name,33+ gameType: def.Name,34+ })35+ break36+ }37+ }38+39+ return options40+}41+42+func seedModeKey(gameType, modeTitle string) string {43+ if modeTitle == "" {44+ return game.NormalizeName(gameType)45+ }46+ return game.NormalizeName(gameType) + "::" + game.NormalizeName(modeTitle)47+}48+49+func definitionHasSeededModes(def game.Definition) bool {50+ for _, item := range def.Modes {51+ if _, ok := item.(game.SeededSpawner); ok {52+ return true53+ }54+ }55+ return false56+}57+58+func findSeedModeIndex(options []seedModeOption, key string) int {59+ if key == "" {60+ return 061+ }62+ for i := range options {63+ if options[i].key == key {64+ return i65+ }66+ }67+ return 068+}69+70+func (m model) enterSeedInputView() (model, tea.Cmd) {71+ ti := textinput.New()72+ ti.Placeholder = "any word or phrase"73+ ti.CharLimit = 6474+ ti.SetWidth(min(m.width, 48))75+76+ options := buildSeedModeOptions(catalog.All)77+ index := findSeedModeIndex(options, m.nav.lastSeedModeKey)78+79+ m.nav.seedInput = ti80+ m.nav.seedModeOptions = options81+ m.nav.seedModeIndex = index82+ m.nav.seedFocus = seedFocusText83+ m.state = seedInputView84+ return m, m.nav.seedInput.Focus()85+}86+87+func (m model) currentSeedMode() seedModeOption {88+ if len(m.nav.seedModeOptions) == 0 {89+ return seedModeOption{label: randomSeedModeLabel}90+ }91+ if m.nav.seedModeIndex < 0 || m.nav.seedModeIndex >= len(m.nav.seedModeOptions) {92+ return m.nav.seedModeOptions[0]93+ }94+ return m.nav.seedModeOptions[m.nav.seedModeIndex]95+}96+97+func (m model) moveSeedMode(step int) model {98+ if len(m.nav.seedModeOptions) == 0 {99+ return m100+ }101+102+ index := m.nav.seedModeIndex + step103+ for index < 0 {104+ index += len(m.nav.seedModeOptions)105+ }106+ m.nav.seedModeIndex = index % len(m.nav.seedModeOptions)107+ m.nav.lastSeedModeKey = m.currentSeedMode().key108+ return m109+}110+111+func (m model) setSeedFocus(focus seedInputFocus) (model, tea.Cmd) {112+ m.nav.seedFocus = focus113+ if focus == seedFocusText {114+ return m, m.nav.seedInput.Focus()115+ }116+ m.nav.seedInput.Blur()117+ return m, nil118+}119+120+func (m model) handleSeedInputUpdate(msg tea.Msg) (model, tea.Cmd) {121+ keyMsg, ok := msg.(tea.KeyPressMsg)122+ if ok {123+ switch keyMsg.String() {124+ case "up", "k":125+ return m.setSeedFocus(seedFocusText)126+ case "down", "j":127+ return m.setSeedFocus(seedFocusMode)128+ case "left", "h":129+ if m.nav.seedFocus == seedFocusMode {130+ return m.moveSeedMode(-1), nil131+ }132+ case "right", "l":133+ if m.nav.seedFocus == seedFocusMode {134+ return m.moveSeedMode(1), nil135+ }136+ }137+ }138+139+ if m.nav.seedFocus != seedFocusText {140+ return m, nil141+ }142+143+ var cmd tea.Cmd144+ m.nav.seedInput, cmd = m.nav.seedInput.Update(msg)145+ return m, cmd146+}147+148+func (m model) seedInputBody() string {149+ selector := "< " + m.currentSeedMode().label + " >"150+ if m.nav.seedFocus == seedFocusMode {151+ selector = ui.SelectedItemStyle().Render(selector)152+ } else {153+ selector = ui.NormalItemStyle().Render(selector)154+ }155+156+ modeLabel := ui.DimItemStyle().Render("game ")157+ modePrefix := " "158+ if m.nav.seedFocus == seedFocusMode {159+ modePrefix = ui.CursorStyle().Render("> ")160+ }161+162+ seedInputView := m.nav.seedInput.View()163+ if m.nav.seedFocus != seedFocusText {164+ seedInputView = strings.Replace(seedInputView, ">", " ", 1)165+ }166+167+ return lipgloss.JoinVertical(168+ lipgloss.Left,169+ seedInputView,170+ "",171+ modePrefix+modeLabel+selector,172+ )173+}@@ -17,7 +17,6 @@ import (
1717 1818 "charm.land/bubbles/v2/key"1919 "charm.land/bubbles/v2/list"20- "charm.land/bubbles/v2/textinput"2120 "charm.land/bubbles/v2/viewport"2221 tea "charm.land/bubbletea/v2"2322 "github.com/charmbracelet/glamour"@@ -28,6 +27,16 @@ const (
2827 helpPanelInsetY = 12928 helpPanelHorizontalTrim = 63029 helpPanelVerticalTrim = 830+ categoryPanelChrome = 831+ categoryBodyMaxWidth = 8632+ categoryBodyMaxHeight = 1433+ categoryMinListWidth = 2434+ categoryMaxListWidth = 3035+ categoryGapWidth = 236+ categoryDetailTrimX = 637+ categoryDetailTrimY = 438+ categoryStackGapHeight = 139+ categoryMinSideBySideW = 723140 )3241 3342 func helpViewportSize(width, height int) (int, int) {@@ -45,6 +54,94 @@ func statsViewportSize(width, height int, cards []stats.Card) (int, int) {
4554 return contentWidth, contentHeight4655 }4756 57+type categoryPickerMetrics struct {58+ bodyWidth int59+ bodyHeight int60+ listWidth int61+ listHeight int62+ detailWidth int63+ detailHeight int64+ stacked bool65+}66+67+func categoryPickerSize(width, height int) categoryPickerMetrics {68+ bodyWidth := min(width, categoryBodyMaxWidth)69+ bodyHeight := min(max(height-categoryPanelChrome, 1), categoryBodyMaxHeight)70+71+ if bodyWidth < categoryMinSideBySideW {72+ listHeight := min(bodyHeight, categoryPickerListHeight())73+ detailHeight := max(bodyHeight-listHeight-categoryStackGapHeight, 1)74+ if detailHeight == 1 && bodyHeight > 1 {75+ listHeight = max(bodyHeight-categoryStackGapHeight-detailHeight, 1)76+ }77+ return categoryPickerMetrics{78+ bodyWidth: bodyWidth,79+ bodyHeight: bodyHeight,80+ listWidth: bodyWidth,81+ listHeight: listHeight,82+ detailWidth: bodyWidth,83+ detailHeight: detailHeight,84+ stacked: true,85+ }86+ }87+88+ listWidth := min(categoryMaxListWidth, max(categoryMinListWidth, bodyWidth/3))89+ detailWidth := max(bodyWidth-listWidth-categoryGapWidth, 1)90+ return categoryPickerMetrics{91+ bodyWidth: bodyWidth,92+ bodyHeight: bodyHeight,93+ listWidth: listWidth,94+ listHeight: bodyHeight,95+ detailWidth: detailWidth,96+ detailHeight: bodyHeight,97+ }98+}99+100+func selectedCategoryName(item list.Item) string {101+ cat, ok := item.(game.Category)102+ if !ok {103+ return ""104+ }105+ return cat.Name106+}107+108+func activeFilterList(m model) *list.Model {109+ switch m.state {110+ case gameSelectView:111+ return &m.nav.gameSelectList112+ case themeSelectView:113+ return &m.theme.list114+ default:115+ return nil116+ }117+}118+119+func (m model) updateCategoryDetailViewport() model {120+ metrics := categoryPickerSize(m.width, m.height)121+ contentWidth := max(metrics.detailWidth-categoryDetailTrimX, 1)122+ contentHeight := max(metrics.detailHeight-categoryDetailTrimY, 1)123+124+ if m.nav.categoryDetail.Width() == 0 || m.nav.categoryDetail.Height() == 0 {125+ m.nav.categoryDetail = viewport.New(126+ viewport.WithWidth(contentWidth),127+ viewport.WithHeight(contentHeight),128+ )129+ }130+ m.nav.categoryDetail.SetWidth(contentWidth)131+ m.nav.categoryDetail.SetHeight(contentHeight)132+ m.nav.categoryDetail.FillHeight = true133+134+ cat, ok := m.nav.gameSelectList.SelectedItem().(game.Category)135+ if !ok {136+ m.nav.categoryDetail.SetContent("")137+ return m138+ }139+140+ m.nav.categoryDetail.SetContent(renderCategoryDetailContent(cat, contentWidth))141+ m.nav.categoryDetail.GotoTop()142+ return m143+}144+48145 func (m model) updateHelpDetailViewport() model {49146 helpWidth, helpHeight := helpViewportSize(m.width, m.height)50147 if m.help.renderer == nil || m.help.rendererWidth != helpWidth {@@ -115,7 +212,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
115212 case optionsMenuView:116213 updateMainMenuCursor(msg, &m.nav.optionsMenu)117214 case seedInputView:118- m.nav.seedInput, cmd = m.nav.seedInput.Update(msg)215+ m, cmd = m.handleSeedInputUpdate(msg)119216 case generatingView:120217 m.spinner, cmd = m.spinner.Update(msg)121218 case gameView:@@ -125,7 +222,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
125222 }126223 m = m.persistCompletionIfSolved()127224 case gameSelectView:225+ prev := selectedCategoryName(m.nav.gameSelectList.SelectedItem())128226 m.nav.gameSelectList, cmd = m.nav.gameSelectList.Update(msg)227+ if selectedCategoryName(m.nav.gameSelectList.SelectedItem()) != prev {228+ m = m.updateCategoryDetailViewport()229+ }129230 case modeSelectView:130231 m.nav.modeSelectList, cmd = m.nav.modeSelectList.Update(msg)131232 case continueView:@@ -154,7 +255,9 @@ func (m model) handleWindowSize(msg tea.WindowSizeMsg) model {
154255 m.width = msg.Width155256 m.height = msg.Height156257 menuW := min(m.width, 64)157- m.nav.gameSelectList.SetSize(menuW, min(m.height, ui.ListHeight(m.nav.gameSelectList)))258+ metrics := categoryPickerSize(m.width, m.height)259+ m.nav.gameSelectList.SetSize(metrics.listWidth, metrics.listHeight)260+ m = m.updateCategoryDetailViewport()158261 if m.state == seedInputView {159262 m.nav.seedInput.SetWidth(min(m.width, 48))160263 }@@ -197,7 +300,28 @@ func (m model) handleGlobalKey(msg tea.KeyPressMsg) (model, tea.Cmd, bool) {
197300 return m, nil, true198301 }199302 303+ if l := activeFilterList(m); l != nil {304+ switch {305+ case l.SettingFilter() && l.FilterValue() == "" && key.Matches(msg, rootKeys.Enter):306+ l.ResetFilter()307+ if m.state == gameSelectView {308+ m = m.updateCategoryDetailViewport()309+ }310+ return m, nil, true311+ case l.SettingFilter() && key.Matches(msg, rootKeys.Enter):312+ return m, nil, false313+ case l.FilterState() != list.Unfiltered && key.Matches(msg, rootKeys.Escape):314+ return m, nil, false315+ }316+ }317+200318 switch {319+ case m.state == gameSelectView && msg.String() == "pgup":320+ m.nav.categoryDetail.PageUp()321+ return m, nil, true322+ case m.state == gameSelectView && msg.String() == "pgdown":323+ m.nav.categoryDetail.PageDown()324+ return m, nil, true201325 case key.Matches(msg, rootKeys.MainMenu):202326 m = saveCurrentGame(m, store.StatusInProgress)203327 m.state = mainMenuView@@ -317,17 +441,12 @@ func (m model) handlePlayMenuEnter() (tea.Model, tea.Cmd) {
317441 return m.handleDailyPuzzle()318442 case "Generate":319443 m.state = gameSelectView444+ m = m.updateCategoryDetailViewport()320445 case "Continue":321446 m.nav.continueTable, m.nav.continueGames = ui.InitContinueTable(m.store, m.height)322447 m.state = continueView323448 case "Enter Seed":324- ti := textinput.New()325- ti.Placeholder = "any word or phrase"326- ti.CharLimit = 64327- ti.SetWidth(min(m.width, 48))328- m.nav.seedInput = ti329- m.state = seedInputView330- return m, m.nav.seedInput.Focus()449+ return m.enterSeedInputView()331450 }332451 return m, nil333452 }@@ -351,7 +470,12 @@ func (m model) handleSeedConfirm() (tea.Model, tea.Cmd) {
351470 return m, nil352471 }353472 473+ selectedMode := m.currentSeedMode()354474 name := sessionflow.SeededName(seed)475+ if selectedMode.key != "" {476+ name = sessionflow.SeededNameForGame(seed, selectedMode.gameType)477+ }478+355479 rec, err := m.store.GetDailyGame(name)356480 if err != nil {357481 log.Printf("failed to check seeded game: %v", err)@@ -368,10 +492,21 @@ func (m model) handleSeedConfirm() (tea.Model, tea.Cmd) {
368492 return m, nil369493 }370494 371- spawner, gameType, modeTitle, err := resolve.SeededMode(seed, catalog.All)372- if err != nil {373- log.Printf("failed to select seeded mode: %v", err)374- return m, nil495+ var spawner game.SeededSpawner496+ var gameType string497+ modeTitle := ""498+ if selectedMode.key == "" {499+ spawner, gameType, modeTitle, err = resolve.SeededMode(seed, catalog.All)500+ if err != nil {501+ log.Printf("failed to select seeded mode: %v", err)502+ return m, nil503+ }504+ } else {505+ spawner, gameType, modeTitle, err = resolve.SeededModeForGame(seed, selectedMode.gameType, catalog.All)506+ if err != nil {507+ log.Printf("failed to select seeded mode for %s: %v", selectedMode.gameType, err)508+ return m, nil509+ }375510 }376511 377512 rng := resolve.RNGFromString(seed)@@ -494,6 +629,7 @@ func (m model) handleEscape() (tea.Model, tea.Cmd) {
494629 m.state = returnState495630 case modeSelectView:496631 m.state = gameSelectView632+ m = m.updateCategoryDetailViewport()497633 case helpDetailView:498634 m.state = helpSelectView499635 case helpSelectView, themeSelectView:@@ -6,13 +6,16 @@ import (
66 "time"77 88 "charm.land/bubbles/v2/key"9+ "charm.land/bubbles/v2/list"910 "charm.land/bubbles/v2/textinput"1011 tea "charm.land/bubbletea/v2"12+ "github.com/FelineStateMachine/puzzletea/catalog"1113 "github.com/FelineStateMachine/puzzletea/daily"1214 "github.com/FelineStateMachine/puzzletea/game"1315 "github.com/FelineStateMachine/puzzletea/lightsout"1416 sessionflow "github.com/FelineStateMachine/puzzletea/session"1517 "github.com/FelineStateMachine/puzzletea/store"18+ "github.com/FelineStateMachine/puzzletea/ui"1619 )1720 1821 type escapeTrackingGame struct {@@ -170,6 +173,92 @@ func TestHandleSeedConfirmDoesNotResumeStatusWhenImportFails(t *testing.T) {
170173 }171174 }172175 176+func TestSeedInputSelectorCyclesAndPersistsDefault(t *testing.T) {177+ options := buildSeedModeOptions(catalog.All)178+ if len(options) < 2 {179+ t.Fatal("expected at least one seeded game option in addition to Random")180+ }181+182+ m := model{183+ state: seedInputView,184+ nav: navigationState{185+ seedModeOptions: options,186+ seedFocus: seedFocusMode,187+ },188+ }189+190+ next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyRight})191+ got := next.(model)192+193+ if got.nav.seedModeIndex != 1 {194+ t.Fatalf("seedModeIndex = %d, want 1", got.nav.seedModeIndex)195+ }196+ if got.nav.lastSeedModeKey != options[1].key {197+ t.Fatalf("lastSeedModeKey = %q, want %q", got.nav.lastSeedModeKey, options[1].key)198+ }199+200+ playMenu := ui.NewMainMenu(playMenuItems)201+ for range 3 {202+ playMenu.CursorDown()203+ }204+205+ reopened, _ := (model{206+ state: playMenuView,207+ nav: navigationState{208+ playMenu: playMenu,209+ lastSeedModeKey: got.nav.lastSeedModeKey,210+ },211+ }).handlePlayMenuEnter()212+ reopenedModel := reopened.(model)213+214+ if reopenedModel.state != seedInputView {215+ t.Fatalf("state = %d, want %d (seedInputView)", reopenedModel.state, seedInputView)216+ }217+ if reopenedModel.currentSeedMode().key != options[1].key {218+ t.Fatalf("reopened seed mode key = %q, want %q", reopenedModel.currentSeedMode().key, options[1].key)219+ }220+}221+222+func TestHandleSeedConfirmUsesSelectedSpecificMode(t *testing.T) {223+ s := openAppTestStore(t)224+ options := buildSeedModeOptions(catalog.All)225+ if len(options) < 2 {226+ t.Fatal("expected at least one seeded game option in addition to Random")227+ }228+229+ ti := textinput.New()230+ ti.SetValue("specific-mode-seed")231+232+ m := model{233+ state: seedInputView,234+ store: s,235+ nav: navigationState{236+ seedInput: ti,237+ seedModeOptions: options,238+ seedModeIndex: 1,239+ },240+ }241+242+ next, _ := m.handleSeedConfirm()243+ got := next.(model)244+245+ if got.state != generatingView {246+ t.Fatalf("state = %d, want %d (generatingView)", got.state, generatingView)247+ }248+ if got.session.spawn == nil {249+ t.Fatal("expected spawn request to be populated")250+ }251+ if got.session.spawn.gameType != options[1].gameType {252+ t.Fatalf("gameType = %q, want %q", got.session.spawn.gameType, options[1].gameType)253+ }254+ if got.session.spawn.modeTitle == "" {255+ t.Fatal("expected a deterministic mode to be selected within the chosen game")256+ }257+ if got.session.spawn.name == sessionflow.SeededName("specific-mode-seed") {258+ t.Fatal("specific seeded game should not reuse the random seeded name")259+ }260+}261+173262 func TestHandleDailyPuzzleDoesNotResumeStatusWhenImportFails(t *testing.T) {174263 s := openAppTestStore(t)175264 now := time.Now()@@ -224,6 +313,29 @@ func TestHandleDailyPuzzleDoesNotResumeStatusWhenImportFails(t *testing.T) {
224313 }225314 }226315 316+func TestGameSelectEscapeClearsAppliedFilterBeforeLeavingView(t *testing.T) {317+ l := ui.InitCategoryList(GameCategories, "Select Category")318+ l.SetFilterText("tak")319+ l.SetFilterState(list.FilterApplied)320+321+ m := model{322+ state: gameSelectView,323+ nav: navigationState{324+ gameSelectList: l,325+ },326+ }327+328+ next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})329+ got := next.(model)330+331+ if got.state != gameSelectView {332+ t.Fatalf("state = %d, want %d (gameSelectView)", got.state, gameSelectView)333+ }334+ if got.nav.gameSelectList.FilterState() != list.Unfiltered {335+ t.Fatalf("filter state = %s, want %s", got.nav.gameSelectList.FilterState(), list.Unfiltered)336+ }337+}338+227339 func openAppTestStore(t *testing.T) *store.Store {228340 t.Helper()229341 s, err := store.Open(filepath.Join(t.TempDir(), "test.db"))@@ -2,7 +2,9 @@ package app
22 33 import (44 "fmt"5+ "strings"56 7+ "github.com/FelineStateMachine/puzzletea/game"68 "github.com/FelineStateMachine/puzzletea/stats"79 "github.com/FelineStateMachine/puzzletea/theme"810 "github.com/FelineStateMachine/puzzletea/ui"@@ -26,17 +28,12 @@ func (m model) View() tea.View {
2628 case seedInputView:2729 panel := ui.Panel(2830 "Enter Seed",29- m.nav.seedInput.View(),30- "enter confirm • esc back",31+ m.seedInputBody(),32+ "↑/↓ change field • ←/→ game • enter confirm • esc back",3133 )3234 content = ui.CenterView(m.width, m.height, panel)3335 case gameSelectView:34- panel := ui.Panel(35- "Select Category",36- m.nav.gameSelectList.View(),37- "↑/↓ navigate • enter select • esc back",38- )39- content = ui.CenterView(m.width, m.height, panel)36+ content = m.gameSelectViewContent()4037 case modeSelectView:4138 panel := ui.Panel(4239 m.nav.selectedCategory.Name+" — Select Mode",@@ -131,6 +128,88 @@ func (m model) View() tea.View {
131128 return v132129 }133130 131+func (m model) gameSelectViewContent() string {132+ metrics := categoryPickerSize(m.width, m.height)133+134+ listView := lipgloss.NewStyle().135+ Width(metrics.listWidth).136+ Height(metrics.listHeight).137+ Render(m.nav.gameSelectList.View())138+139+ detailBox := lipgloss.NewStyle().140+ Width(metrics.detailWidth).141+ Height(metrics.detailHeight).142+ Border(lipgloss.RoundedBorder()).143+ BorderForeground(theme.Current().Border).144+ Padding(1, 2).145+ Render(m.nav.categoryDetail.View())146+147+ body := lipgloss.JoinHorizontal(lipgloss.Top, listView, strings.Repeat(" ", categoryGapWidth), detailBox)148+ if metrics.stacked {149+ body = lipgloss.JoinVertical(lipgloss.Left, listView, "", detailBox)150+ }151+ body = lipgloss.NewStyle().152+ Width(metrics.bodyWidth).153+ Height(metrics.bodyHeight).154+ Render(body)155+156+ panel := ui.Panel(157+ "Select Category",158+ body,159+ "↑/↓ navigate • pgup/pgdn details • / filter • enter select • esc back",160+ )161+ return ui.CenterView(m.width, m.height, panel)162+}163+164+func categoryPickerListHeight() int {165+ const (166+ maxVisibleItems = 8167+ filterChrome = 2168+ )169+170+ return maxVisibleItems + filterChrome171+}172+173+func renderCategoryDetailContent(cat game.Category, width int) string {174+ p := theme.Current()175+ desc := lipgloss.NewStyle().176+ Foreground(p.FG).177+ Width(width).178+ Render(cat.Desc)179+180+ meta := ui.DimItemStyle().Render(fmt.Sprintf("%d modes available", len(cat.Modes)))181+ content := lipgloss.JoinVertical(lipgloss.Left,182+ ui.PanelTitle().Render(cat.Name),183+ meta,184+ "",185+ desc,186+ "",187+ ui.SelectedItemStyle().Render("Modes"),188+ renderModeList(cat, width),189+ )190+ return content191+}192+193+func renderModeList(cat game.Category, width int) string {194+ if len(cat.Modes) == 0 {195+ return ui.DimItemStyle().Render("No modes available.")196+ }197+198+ lines := make([]string, 0, len(cat.Modes))199+ for _, item := range cat.Modes {200+ mode, ok := item.(game.Mode)201+ if !ok {202+ continue203+ }204+ line := lipgloss.NewStyle().Width(width).Render("• " + mode.Title())205+ lines = append(lines, line)206+ }207+ if len(lines) == 0 {208+ return ui.DimItemStyle().Render("No modes available.")209+ }210+ return strings.Join(lines, "\n")211+}212+134213 // themeSelectViewContent renders the theme picker as a side-by-side layout:135214 // theme list on the left, color preview panel on the right.136215 func (m model) themeSelectViewContent() string {@@ -15,6 +15,7 @@ import (
1515 "github.com/FelineStateMachine/puzzletea/shikaku"1616 "github.com/FelineStateMachine/puzzletea/sudoku"1717 "github.com/FelineStateMachine/puzzletea/takuzu"18+ "github.com/FelineStateMachine/puzzletea/takuzuplus"1819 "github.com/FelineStateMachine/puzzletea/wordsearch"1920 )2021 @@ -30,6 +31,7 @@ var All = []game.Definition{
3031 shikaku.Definition,3132 sudoku.Definition,3233 takuzu.Definition,34+ takuzuplus.Definition,3335 wordsearch.Definition,3436 }3537 @@ -1,6 +1,7 @@
11 package fillomino22 33 import (4+ "image"45 "image/color"56 "math/rand/v2"67 "strings"@@ -256,6 +257,177 @@ func TestConflictGapBackgroundUsesConflictColor(t *testing.T) {
256257 }257258 }258259 260+func TestBridgeFillUsesCrosshairColorAcrossEmptyBoundary(t *testing.T) {261+ m := Model{262+ width: 2,263+ height: 1,264+ grid: grid{{0, 0}},265+ conflicts: validateGridState(grid{{0, 0}}).conflicts,266+ cursor: pointCursor(0, 0),267+ }268+ renderState := buildRenderGridState(m)269+270+ got := bridgeFill(m, renderState, game.DynamicGridBridge{271+ Kind: game.DynamicGridBridgeVertical,272+ X: 1,273+ Y: 0,274+ Count: 2,275+ Zone: renderState.zones[0][0],276+ Uniform: true,277+ Cells: [4]image.Point{278+ {X: 0, Y: 0},279+ {X: 1, Y: 0},280+ },281+ })282+ if !sameColor(got, theme.Current().Surface) {283+ t.Fatal("expected empty bridge on cursor crosshair to use surface background")284+ }285+}286+287+func TestBridgeFillDoesNotExpandIntoOpenInterior(t *testing.T) {288+ m := Model{289+ width: 3,290+ height: 3,291+ grid: grid{{0, 0, 0}, {0, 0, 0}, {0, 0, 0}},292+ conflicts: validateGridState(grid{{0, 0, 0}, {0, 0, 0}, {0, 0, 0}}).conflicts,293+ cursor: pointCursor(1, 1),294+ }295+ renderState := buildRenderGridState(m)296+297+ if got := bridgeFill(m, renderState, game.DynamicGridBridge{298+ Kind: game.DynamicGridBridgeVertical,299+ X: 1,300+ Y: 1,301+ Count: 2,302+ Zone: renderState.zones[1][0],303+ Uniform: true,304+ Cells: [4]image.Point{305+ {X: 0, Y: 1},306+ {X: 1, Y: 1},307+ },308+ }); got != nil {309+ t.Fatal("expected fully open interior bridge to remain unfilled")310+ }311+}312+313+func TestBridgeFillVerticalUsesRowCrosshairOnly(t *testing.T) {314+ m := Model{315+ width: 2,316+ height: 2,317+ grid: grid{{0, 0}, {0, 0}},318+ conflicts: validateGridState(grid{{0, 0}, {0, 0}}).conflicts,319+ cursor: pointCursor(0, 1),320+ }321+ renderState := buildRenderGridState(m)322+323+ if got := bridgeFill(m, renderState, game.DynamicGridBridge{324+ Kind: game.DynamicGridBridgeVertical,325+ X: 1,326+ Y: 0,327+ Count: 2,328+ Zone: renderState.zones[0][0],329+ Uniform: true,330+ Cells: [4]image.Point{331+ {X: 0, Y: 0},332+ {X: 1, Y: 0},333+ },334+ }); got != nil {335+ t.Fatal("expected vertical bridge to ignore column-only crosshair match")336+ }337+}338+339+func TestBridgeFillHorizontalUsesColumnCrosshairOnly(t *testing.T) {340+ m := Model{341+ width: 2,342+ height: 2,343+ grid: grid{{0, 0}, {0, 0}},344+ conflicts: validateGridState(grid{{0, 0}, {0, 0}}).conflicts,345+ cursor: pointCursor(1, 0),346+ }347+ renderState := buildRenderGridState(m)348+349+ if got := bridgeFill(m, renderState, game.DynamicGridBridge{350+ Kind: game.DynamicGridBridgeHorizontal,351+ X: 0,352+ Y: 1,353+ Count: 2,354+ Zone: renderState.zones[0][0],355+ Uniform: true,356+ Cells: [4]image.Point{357+ {X: 0, Y: 0},358+ {X: 0, Y: 1},359+ },360+ }); got != nil {361+ t.Fatal("expected horizontal bridge to ignore row-only crosshair match")362+ }363+}364+365+func TestBridgeFillExtendsThroughClosedVerticalSeparator(t *testing.T) {366+ m := Model{367+ width: 2,368+ height: 1,369+ grid: grid{{1, 2}},370+ conflicts: validateGridState(grid{{1, 2}}).conflicts,371+ cursor: pointCursor(0, 0),372+ }373+ renderState := buildRenderGridState(m)374+375+ got := bridgeFill(m, renderState, game.DynamicGridBridge{376+ Kind: game.DynamicGridBridgeVertical,377+ X: 1,378+ Y: 0,379+ })380+ if !sameColor(got, theme.Current().Surface) {381+ t.Fatal("expected closed vertical separator on cursor row to use surface background")382+ }383+}384+385+func TestBridgeFillExtendsThroughClosedHorizontalSeparator(t *testing.T) {386+ m := Model{387+ width: 1,388+ height: 2,389+ grid: grid{{1}, {2}},390+ conflicts: validateGridState(grid{{1}, {2}}).conflicts,391+ cursor: pointCursor(0, 0),392+ }393+ renderState := buildRenderGridState(m)394+395+ got := bridgeFill(m, renderState, game.DynamicGridBridge{396+ Kind: game.DynamicGridBridgeHorizontal,397+ X: 0,398+ Y: 1,399+ })400+ if !sameColor(got, theme.Current().Surface) {401+ t.Fatal("expected closed horizontal separator on cursor column to use surface background")402+ }403+}404+405+func TestBridgeFillPreservesActiveRegionBackground(t *testing.T) {406+ m := Model{407+ width: 2,408+ height: 1,409+ grid: grid{{2, 2}},410+ conflicts: validateGridState(grid{{2, 2}}).conflicts,411+ cursor: pointCursor(0, 0),412+ }413+ renderState := buildRenderGridState(m)414+415+ if got := bridgeFill(m, renderState, game.DynamicGridBridge{416+ Kind: game.DynamicGridBridgeVertical,417+ X: 1,418+ Y: 0,419+ Count: 2,420+ Zone: renderState.zones[0][0],421+ Uniform: true,422+ Cells: [4]image.Point{423+ {X: 0, Y: 0},424+ {X: 1, Y: 0},425+ },426+ }); got != nil {427+ t.Fatal("expected active region bridge to defer to region background")428+ }429+}430+259431 func TestGridViewUsesUniformRows(t *testing.T) {260432 conflicts := validateGridState(grid{{2, 2, 1}, {3, 3, 3}, {1, 2, 2}}).conflicts261433 m := Model{@@ -121,17 +121,133 @@ func gridView(m Model) string {
121121 }122122 },123123 BridgeFill: func(bridge game.DynamicGridBridge) color.Color {124- for i := 0; i < bridge.Count; i++ {125- cell := bridge.Cells[i]126- if m.conflicts[cell.Y][cell.X] {127- return game.ConflictBG()128- }129- }130- return nil124+ return bridgeFill(m, renderState, bridge)131125 },132126 })133127 }134128 129+func bridgeFill(m Model, renderState renderGridState, bridge game.DynamicGridBridge) color.Color {130+ for i := 0; i < bridge.Count; i++ {131+ cell := bridge.Cells[i]132+ if m.conflicts[cell.Y][cell.X] {133+ return game.ConflictBG()134+ }135+ }136+137+ if m.solved {138+ return nil139+ }140+141+ if bridge.Uniform {142+ switch {143+ case renderState.completed[bridge.Zone] != nil:144+ return nil145+ case renderState.activeZone >= 0 && bridge.Zone == renderState.activeZone:146+ return nil147+ }148+ }149+150+ if bridge.Count > 0 && !bridgeTouchesBorder(renderState, bridge, m.width, m.height) {151+ return nil152+ }153+154+ if bridgeOnCrosshairAxis(m.cursor, bridge) {155+ return theme.Current().Surface156+ }157+158+ return nil159+}160+161+func bridgeOnCrosshairAxis(cursor game.Cursor, bridge game.DynamicGridBridge) bool {162+ switch bridge.Kind {163+ case game.DynamicGridBridgeVertical:164+ if bridge.Count == 0 {165+ return bridge.Y == cursor.Y166+ }167+ for i := 0; i < bridge.Count; i++ {168+ if bridge.Cells[i].Y == cursor.Y {169+ return true170+ }171+ }172+ case game.DynamicGridBridgeHorizontal:173+ if bridge.Count == 0 {174+ return bridge.X == cursor.X175+ }176+ for i := 0; i < bridge.Count; i++ {177+ if bridge.Cells[i].X == cursor.X {178+ return true179+ }180+ }181+ }182+ return false183+}184+185+func bridgeTouchesBorder(renderState renderGridState, bridge game.DynamicGridBridge, width, height int) bool {186+ switch bridge.Kind {187+ case game.DynamicGridBridgeVertical:188+ return zoneJunctionRune(renderState.zones, width, height, bridge.X, bridge.Y) != ' ' ||189+ zoneJunctionRune(renderState.zones, width, height, bridge.X, bridge.Y+1) != ' '190+ case game.DynamicGridBridgeHorizontal:191+ return zoneJunctionRune(renderState.zones, width, height, bridge.X, bridge.Y) != ' ' ||192+ zoneJunctionRune(renderState.zones, width, height, bridge.X+1, bridge.Y) != ' '193+ default:194+ return false195+ }196+}197+198+func zoneHorizontalEdge(zones [][]int, width, height, x, y int) bool {199+ switch {200+ case y <= 0, y >= height:201+ return true202+ default:203+ return zones[y-1][x] != zones[y][x]204+ }205+}206+207+func zoneVerticalEdge(zones [][]int, width, height, x, y int) bool {208+ _ = height209+ switch {210+ case x <= 0, x >= width:211+ return true212+ default:213+ return zones[y][x-1] != zones[y][x]214+ }215+}216+217+func zoneJunctionRune(zones [][]int, width, height, x, y int) rune {218+ north := y > 0 && zoneVerticalEdge(zones, width, height, x, y-1)219+ south := y < height && zoneVerticalEdge(zones, width, height, x, y)220+ west := x > 0 && zoneHorizontalEdge(zones, width, height, x-1, y)221+ east := x < width && zoneHorizontalEdge(zones, width, height, x, y)222+223+ switch {224+ case north && south && west && east:225+ return '┼'226+ case north && south && west:227+ return '┤'228+ case north && south && east:229+ return '├'230+ case west && east && north:231+ return '┴'232+ case west && east && south:233+ return '┬'234+ case south && east:235+ return '┌'236+ case south && west:237+ return '┐'238+ case north && east:239+ return '└'240+ case north && west:241+ return '┘'242+ case north || south:243+ return '│'244+ case west || east:245+ return '─'246+ default:247+ return ' '248+ }249+}250+135251 func buildRenderGridState(m Model) renderGridState {136252 height := len(m.grid)137253 if height == 0 {@@ -6,6 +6,7 @@ import (
66 "strings"77 88 "charm.land/lipgloss/v2"9+ "github.com/FelineStateMachine/puzzletea/theme"910 )1011 1112 const DynamicGridCellWidth = 3@@ -40,14 +41,16 @@ type DynamicGridBridge struct {
4041 }4142 4243 type DynamicGridSpec struct {43- Width int44- Height int45- Solved bool46- Colors *GridBorderColors47- Cell func(x, y int) string48- ZoneAt func(x, y int) int49- ZoneFill func(zone int) color.Color50- BridgeFill func(bridge DynamicGridBridge) color.Color44+ Width int45+ Height int46+ Solved bool47+ Colors *GridBorderColors48+ Cell func(x, y int) string49+ ZoneAt func(x, y int) int50+ ZoneFill func(zone int) color.Color51+ BridgeFill func(bridge DynamicGridBridge) color.Color52+ VerticalBridgeText func(x, y int) string53+ HorizontalBridgeText func(x, y int) string5154 }5255 5356 func RenderDynamicGrid(spec DynamicGridSpec) string {@@ -127,24 +130,14 @@ func dynamicGridContentRow(spec DynamicGridSpec, y int) string {
127130 var b strings.Builder128131 for x := range spec.Width {129132 bridge := dynamicGridVerticalBridge(spec, x, y)130- b.WriteString(dynamicGridRenderBorderChar(131- verticalEdgeRune(dynamicGridHasVerticalEdge(spec, x, y)),132- dynamicGridColors(spec),133- spec.Solved,134- dynamicGridBridgeBackground(spec, bridge),135- ))133+ b.WriteString(dynamicGridRenderVerticalBridge(spec, x, y, bridge))136134 if spec.Cell != nil {137135 b.WriteString(spec.Cell(x, y))138136 }139137 }140138 141139 bridge := dynamicGridVerticalBridge(spec, spec.Width, y)142- b.WriteString(dynamicGridRenderBorderChar(143- verticalEdgeRune(dynamicGridHasVerticalEdge(spec, spec.Width, y)),144- dynamicGridColors(spec),145- spec.Solved,146- dynamicGridBridgeBackground(spec, bridge),147- ))140+ b.WriteString(dynamicGridRenderVerticalBridge(spec, spec.Width, y, bridge))148141 return b.String()149142 }150143 @@ -161,20 +154,8 @@ func dynamicGridBoundaryRow(spec DynamicGridSpec, y int) string {
161154 if x == spec.Width {162155 continue163156 }164-165- ch := ' '166- if dynamicGridHasHorizontalEdge(spec, x, y) {167- ch = '─'168- }169- b.WriteString(strings.Repeat(170- dynamicGridRenderBorderChar(171- ch,172- dynamicGridColors(spec),173- spec.Solved,174- dynamicGridBridgeBackground(spec, dynamicGridHorizontalBridge(spec, x, y)),175- ),176- DynamicGridCellWidth,177- ))157+ bridge := dynamicGridHorizontalBridge(spec, x, y)158+ b.WriteString(dynamicGridRenderHorizontalBridge(spec, x, y, bridge))178159 }179160 return b.String()180161 }@@ -203,6 +184,82 @@ func dynamicGridRenderBorderChar(ch rune, colors GridBorderColors, solved bool,
203184 Render(string(ch))204185 }205186 187+func dynamicGridRenderVerticalBridge(spec DynamicGridSpec, x, y int, bridge DynamicGridBridge) string {188+ if dynamicGridHasVerticalEdge(spec, x, y) {189+ return dynamicGridRenderBorderChar(190+ verticalEdgeRune(true),191+ dynamicGridColors(spec),192+ spec.Solved,193+ dynamicGridBridgeBackground(spec, bridge),194+ )195+ }196+197+ if spec.VerticalBridgeText != nil {198+ if text := strings.TrimSpace(spec.VerticalBridgeText(x, y)); text != "" {199+ return dynamicGridRenderBridgeText(text, 1, dynamicGridColors(spec), spec.Solved, dynamicGridBridgeBackground(spec, bridge))200+ }201+ }202+203+ return dynamicGridRenderBorderChar(204+ verticalEdgeRune(false),205+ dynamicGridColors(spec),206+ spec.Solved,207+ dynamicGridBridgeBackground(spec, bridge),208+ )209+}210+211+func dynamicGridRenderHorizontalBridge(spec DynamicGridSpec, x, y int, bridge DynamicGridBridge) string {212+ if dynamicGridHasHorizontalEdge(spec, x, y) {213+ return strings.Repeat(214+ dynamicGridRenderBorderChar(215+ '─',216+ dynamicGridColors(spec),217+ spec.Solved,218+ dynamicGridBridgeBackground(spec, bridge),219+ ),220+ DynamicGridCellWidth,221+ )222+ }223+224+ if spec.HorizontalBridgeText != nil {225+ if text := strings.TrimSpace(spec.HorizontalBridgeText(x, y)); text != "" {226+ return dynamicGridRenderBridgeText(text, DynamicGridCellWidth, dynamicGridColors(spec), spec.Solved, dynamicGridBridgeBackground(spec, bridge))227+ }228+ }229+230+ return strings.Repeat(231+ dynamicGridRenderBorderChar(232+ ' ',233+ dynamicGridColors(spec),234+ spec.Solved,235+ dynamicGridBridgeBackground(spec, bridge),236+ ),237+ DynamicGridCellWidth,238+ )239+}240+241+func dynamicGridRenderBridgeText(text string, width int, colors GridBorderColors, solved bool, bg color.Color) string {242+ fg := colors.BorderFG243+ if solved {244+ fg = colors.SolvedBorderFG245+ }246+ if bg == nil {247+ bg = colors.BackgroundBG248+ if solved {249+ bg = colors.SolvedBG250+ }251+ } else {252+ fg = theme.TextOnBG(bg)253+ }254+255+ return lipgloss.NewStyle().256+ Width(width).257+ AlignHorizontal(lipgloss.Center).258+ Foreground(fg).259+ Background(bg).260+ Render(text)261+}262+206263 func dynamicGridHasHorizontalEdge(spec DynamicGridSpec, x, y int) bool {207264 switch {208265 case y <= 0, y >= spec.Height:@@ -256,9 +313,6 @@ func dynamicGridJunctionRune(spec DynamicGridSpec, x, y int) rune {
256313 }257314 258315 func dynamicGridBridgeBackground(spec DynamicGridSpec, bridge DynamicGridBridge) color.Color {259- if bridge.Count == 0 {260- return nil261- }262316 if spec.BridgeFill != nil {263317 if bg := spec.BridgeFill(bridge); bg != nil {264318 return bg@@ -151,3 +151,51 @@ func TestDynamicGridOriginStableAcrossSecondaryRows(t *testing.T) {
151151 t.Fatalf("origin changed from (%d,%d) to (%d,%d)", shortX, shortY, longX, longY)152152 }153153 }154+155+func TestRenderDynamicGridVerticalBridgeText(t *testing.T) {156+ view := RenderDynamicGrid(DynamicGridSpec{157+ Width: 2,158+ Height: 1,159+ Cell: func(_, _ int) string {160+ return " "161+ },162+ ZoneAt: func(_, _ int) int {163+ return 0164+ },165+ VerticalBridgeText: func(x, y int) string {166+ if x == 1 && y == 0 {167+ return "x"168+ }169+ return ""170+ },171+ })172+173+ lines := strings.Split(ansi.Strip(view), "\n")174+ if got := []rune(lines[1])[4]; got != 'x' {175+ t.Fatalf("vertical bridge rune = %q, want %q", got, 'x')176+ }177+}178+179+func TestRenderDynamicGridHorizontalBridgeText(t *testing.T) {180+ view := RenderDynamicGrid(DynamicGridSpec{181+ Width: 1,182+ Height: 2,183+ Cell: func(_, _ int) string {184+ return " "185+ },186+ ZoneAt: func(_, _ int) int {187+ return 0188+ },189+ HorizontalBridgeText: func(x, y int) string {190+ if x == 0 && y == 1 {191+ return "="192+ }193+ return ""194+ },195+ })196+197+ lines := strings.Split(ansi.Strip(view), "\n")198+ if got := []rune(lines[2])[2]; got != '=' {199+ t.Fatalf("horizontal bridge rune = %q, want %q", got, '=')200+ }201+}@@ -134,6 +134,46 @@ func TestParseJSONLFileHydratesTakuzuFromSave(t *testing.T) {
134134 }135135 }136136 137+func TestParseJSONLFileHydratesTakuzuPlusFromSave(t *testing.T) {138+ path := filepath.Join(t.TempDir(), "takuzuplus-pack.jsonl")139+ record := JSONLRecord{140+ Schema: ExportSchemaV1,141+ Pack: JSONLPackMeta{142+ Generated: "2026-02-22T10:00:00Z",143+ Version: "v-test",144+ Category: "Takuzu+",145+ ModeSelection: "Beginner",146+ Count: 1,147+ },148+ Puzzle: JSONLPuzzle{149+ Index: 1,150+ Name: "binary-link",151+ Game: "Takuzu+",152+ Mode: "Beginner",153+ Save: json.RawMessage(`{"size":4,"state":"01..\n10..\n0011\n1111","provided":"#...\n.##.\n####\n#...","horizontal_relations":"=x.\n...\n..=\n...","vertical_relations":"x...\n.=..\n...x"}`),154+ },155+ }156+ writeSingleJSONLRecord(t, path, record)157+158+ doc, err := ParseJSONLFile(path)159+ if err != nil {160+ t.Fatal(err)161+ }162+ if got, want := len(doc.Puzzles), 1; got != want {163+ t.Fatalf("puzzles = %d, want %d", got, want)164+ }165+ payload, ok := doc.Puzzles[0].PrintPayload.(*TakuzuData)166+ if !ok || payload == nil {167+ t.Fatal("expected takuzu+ print payload from save hydration")168+ }169+ if got, want := payload.HorizontalRelations[0][0], "="; got != want {170+ t.Fatalf("horizontal relation = %q, want %q", got, want)171+ }172+ if got, want := payload.VerticalRelations[1][1], "="; got != want {173+ t.Fatalf("vertical relation = %q, want %q", got, want)174+ }175+}176+137177 func TestParseJSONLFileHydratesSudokuFromSave(t *testing.T) {138178 path := filepath.Join(t.TempDir(), "sudoku.jsonl")139179 record := JSONLRecord{@@ -458,6 +498,9 @@ func ensureJSONLTestAdapters() {
458498 register("Takuzu", buildPayloadAdapter("Takuzu", func(save []byte) (any, error) {459499 return ParseTakuzuPrintData(save)460500 }), "takuzu")501+ register("Takuzu+", buildPayloadAdapter("Takuzu+", func(save []byte) (any, error) {502+ return ParseTakuzuPlusPrintData(save)503+ }), "takuzu+", "takuzu plus", "binario+")461504 register("Sudoku", buildPayloadAdapter("Sudoku", func(save []byte) (any, error) {462505 return ParseSudokuPrintData(save)463506 }), "sudoku")@@ -58,6 +58,14 @@ type takuzuSave struct {
5858 Provided string `json:"provided"`5959 }6060 61+type takuzuPlusSave struct {62+ Size int `json:"size"`63+ State string `json:"state"`64+ Provided string `json:"provided"`65+ HorizontalRelations string `json:"horizontal_relations"`66+ VerticalRelations string `json:"vertical_relations"`67+}68+6169 type sudokuSave struct {6270 Provided []sudokuCell `json:"provided"`6371 }@@ -316,9 +324,90 @@ func ParseTakuzuPrintData(saveData []byte) (*TakuzuData, error) {
316324 }317325 318326 return &TakuzuData{319- Size: size,320- Givens: givens,321- GroupEveryTwo: true,327+ Size: size,328+ Givens: givens,329+ HorizontalRelations: make([][]string, size),330+ VerticalRelations: make([][]string, max(size-1, 0)),331+ GroupEveryTwo: true,332+ }, nil333+}334+335+func ParseTakuzuPlusPrintData(saveData []byte) (*TakuzuData, error) {336+ if len(strings.TrimSpace(string(saveData))) == 0 {337+ return nil, nil338+ }339+340+ var save takuzuPlusSave341+ if err := json.Unmarshal(saveData, &save); err != nil {342+ return nil, fmt.Errorf("decode takuzu+ save: %w", err)343+ }344+345+ stateRows := splitNormalizedLines(save.State)346+ providedRows := splitNormalizedLines(save.Provided)347+ horizontalRows := splitNormalizedLines(save.HorizontalRelations)348+ verticalRows := splitNormalizedLines(save.VerticalRelations)349+350+ size := save.Size351+ if size <= 0 {352+ size = max(max(len(stateRows), len(providedRows)), max(len(horizontalRows), len(verticalRows)+1))353+ }354+ if size <= 0 {355+ return nil, nil356+ }357+358+ givens := make([][]string, size)359+ horizontal := make([][]string, size)360+ vertical := make([][]string, max(size-1, 0))361+ for y := 0; y < size; y++ {362+ givens[y] = make([]string, size)363+ horizontal[y] = make([]string, max(size-1, 0))364+365+ var stateRunes []rune366+ if y < len(stateRows) {367+ stateRunes = []rune(stateRows[y])368+ }369+ var providedRunes []rune370+ if y < len(providedRows) {371+ providedRunes = []rune(providedRows[y])372+ }373+ var relationRunes []rune374+ if y < len(horizontalRows) {375+ relationRunes = []rune(horizontalRows[y])376+ }377+378+ for x := 0; x < size; x++ {379+ if x < size-1 && x < len(relationRunes) && (relationRunes[x] == '=' || relationRunes[x] == 'x') {380+ horizontal[y][x] = string(relationRunes[x])381+ }382+ if x >= len(providedRunes) || providedRunes[x] != '#' || x >= len(stateRunes) {383+ continue384+ }385+ if stateRunes[x] != '0' && stateRunes[x] != '1' {386+ continue387+ }388+ givens[y][x] = string(stateRunes[x])389+ }390+ }391+392+ for y := 0; y < size-1; y++ {393+ vertical[y] = make([]string, size)394+ if y >= len(verticalRows) {395+ continue396+ }397+ relationRunes := []rune(verticalRows[y])398+ for x := 0; x < size && x < len(relationRunes); x++ {399+ if relationRunes[x] == '=' || relationRunes[x] == 'x' {400+ vertical[y][x] = string(relationRunes[x])401+ }402+ }403+ }404+405+ return &TakuzuData{406+ Size: size,407+ Givens: givens,408+ HorizontalRelations: horizontal,409+ VerticalRelations: vertical,410+ GroupEveryTwo: true,322411 }, nil323412 }324413 @@ -182,6 +182,33 @@ func TestParseTakuzuPrintData(t *testing.T) {
182182 }183183 }184184 185+func TestParseTakuzuPlusPrintData(t *testing.T) {186+ save := []byte(`{"size":4,"state":"01..\n10..\n0011\n1111","provided":"#...\n.##.\n####\n#...","horizontal_relations":"=x.\n...\n..=\n...","vertical_relations":"x...\n.=..\n...x"}`)187+188+ data, err := ParseTakuzuPlusPrintData(save)189+ if err != nil {190+ t.Fatal(err)191+ }192+ if data == nil {193+ t.Fatal("expected takuzu+ print data")194+ }195+ if got, want := data.HorizontalRelations[0][0], "="; got != want {196+ t.Fatalf("horizontal relation[0][0] = %q, want %q", got, want)197+ }198+ if got, want := data.HorizontalRelations[0][1], "x"; got != want {199+ t.Fatalf("horizontal relation[0][1] = %q, want %q", got, want)200+ }201+ if got, want := data.VerticalRelations[0][0], "x"; got != want {202+ t.Fatalf("vertical relation[0][0] = %q, want %q", got, want)203+ }204+ if got, want := data.VerticalRelations[1][1], "="; got != want {205+ t.Fatalf("vertical relation[1][1] = %q, want %q", got, want)206+ }207+ if got, want := data.Givens[1][1], "0"; got != want {208+ t.Fatalf("given[1][1] = %q, want %q", got, want)209+ }210+}211+185212 func TestParseFillominoPrintData(t *testing.T) {186213 save := []byte(`{"width":3,"height":2,"state":"1 . 2\n. 3 .","provided":"#.#\n.#."}`)187214 @@ -86,6 +86,41 @@ type seededEntry struct {
8686 mode string8787 }8888 89+func seededModeForDefinition(seed string, def game.Definition) (seededEntry, bool) {90+ var best seededEntry91+ var bestHash uint6492+ found := false93+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 {101+ continue102+ }103+ h := fnv.New64a()104+ h.Write([]byte(seed))105+ h.Write([]byte{0})106+ h.Write([]byte(def.Name))107+ h.Write([]byte{0})108+ h.Write([]byte(mode.Title()))109+ score := h.Sum64()110+ if !found || score > bestHash {111+ bestHash = score112+ best = seededEntry{113+ spawner: s,114+ gameType: def.Name,115+ mode: mode.Title(),116+ }117+ found = true118+ }119+ }120+121+ return best, found122+}123+89124 // SeededMode selects a game type and mode from all available modes across90125 // all categories using rendezvous hashing (highest random weight). Each91126 // eligible mode is scored by hashing the seed string together with its@@ -99,31 +134,21 @@ func SeededMode(seed string, definitions []game.Definition) (game.SeededSpawner,
99134 var bestHash uint64100135 found := false101136 for _, def := range definitions {102- for _, modeItem := range def.Modes {103- mode, ok := modeItem.(game.Mode)104- if !ok {105- continue106- }107- s, ok := modeItem.(game.SeededSpawner)108- if !ok {109- continue110- }111- h := fnv.New64a()112- h.Write([]byte(seed))113- h.Write([]byte{0})114- h.Write([]byte(def.Name))115- h.Write([]byte{0})116- h.Write([]byte(mode.Title()))117- score := h.Sum64()118- if !found || score > bestHash {119- bestHash = score120- best = seededEntry{121- spawner: s,122- gameType: def.Name,123- mode: mode.Title(),124- }125- found = true126- }137+ entry, ok := seededModeForDefinition(seed, def)138+ if !ok {139+ continue140+ }141+ h := fnv.New64a()142+ h.Write([]byte(seed))143+ h.Write([]byte{0})144+ h.Write([]byte(entry.gameType))145+ h.Write([]byte{0})146+ h.Write([]byte(entry.mode))147+ score := h.Sum64()148+ if !found || score > bestHash {149+ bestHash = score150+ best = entry151+ found = true127152 }128153 }129154 if !found {@@ -131,3 +156,23 @@ func SeededMode(seed string, definitions []game.Definition) (game.SeededSpawner,
131156 }132157 return best.spawner, best.gameType, best.mode, nil133158 }159+160+// SeededModeForGame deterministically selects a seeded mode within a single161+// game definition, so the same seed and game name always produce the same162+// puzzle for that game.163+func SeededModeForGame(seed, gameType string, definitions []game.Definition) (game.SeededSpawner, string, string, error) {164+ norm := Normalize(gameType)165+ for _, def := range definitions {166+ if Normalize(def.Name) != norm {167+ continue168+ }169+170+ entry, ok := seededModeForDefinition(seed, def)171+ if !ok {172+ return nil, "", "", fmt.Errorf("game %q has no seeded modes", def.Name)173+ }174+ return entry.spawner, entry.gameType, entry.mode, nil175+ }176+177+ return nil, "", "", fmt.Errorf("unknown game %q", gameType)178+}@@ -1,6 +1,7 @@
11 package rippleeffect22 33 import (4+ "image"45 "image/color"56 "math/rand/v2"67 "strings"@@ -418,6 +419,133 @@ func TestActiveCageBridgeBackgrounds(t *testing.T) {
418419 }419420 }420421 422+func TestBridgeFillUsesOpenAnchoredCrosshair(t *testing.T) {423+ geo, err := buildGeometry(3, 2, []Cage{424+ {ID: 0, Size: 2, Cells: []Cell{{0, 0}, {0, 1}}},425+ {ID: 1, Size: 2, Cells: []Cell{{1, 0}, {2, 0}}},426+ {ID: 2, Size: 1, Cells: []Cell{{1, 1}}},427+ {ID: 3, Size: 1, Cells: []Cell{{2, 1}}},428+ })429+ if err != nil {430+ t.Fatal(err)431+ }432+433+ m := Model{434+ width: 3,435+ height: 2,436+ grid: newGrid(3, 2),437+ givens: newGrid(3, 2),438+ conflicts: newConflictGrid(3, 2),439+ cursor: pointCursor(0, 0),440+ geo: geo,441+ }442+ bridgeBG := bridgeBackgrounds(m, nil)443+444+ got := bridgeFill(m, bridgeBG, game.DynamicGridBridge{445+ Kind: game.DynamicGridBridgeVertical,446+ X: 2,447+ Y: 0,448+ Count: 2,449+ Zone: geo.cageGrid[0][1],450+ Uniform: true,451+ Cells: [4]image.Point{452+ {X: 1, Y: 0},453+ {X: 2, Y: 0},454+ },455+ })456+ if !sameColor(got, theme.Current().Surface) {457+ t.Fatal("expected anchored open bridge on cursor row to use crosshair background")458+ }459+}460+461+func TestBridgeFillDoesNotExpandIntoOpenInterior(t *testing.T) {462+ geo, err := buildGeometry(4, 3, []Cage{463+ {ID: 0, Size: 1, Cells: []Cell{{0, 1}}},464+ {ID: 1, Size: 9, Cells: []Cell{{1, 0}, {2, 0}, {3, 0}, {1, 1}, {2, 1}, {3, 1}, {1, 2}, {2, 2}, {3, 2}}},465+ {ID: 2, Size: 1, Cells: []Cell{{0, 0}}},466+ {ID: 3, Size: 1, Cells: []Cell{{0, 2}}},467+ })468+ if err != nil {469+ t.Fatal(err)470+ }471+472+ m := Model{473+ width: 4,474+ height: 3,475+ grid: newGrid(4, 3),476+ givens: newGrid(4, 3),477+ conflicts: newConflictGrid(4, 3),478+ cursor: pointCursor(0, 1),479+ geo: geo,480+ }481+ bridgeBG := bridgeBackgrounds(m, nil)482+483+ if got := bridgeFill(m, bridgeBG, game.DynamicGridBridge{484+ Kind: game.DynamicGridBridgeVertical,485+ X: 2,486+ Y: 1,487+ Count: 2,488+ Zone: geo.cageGrid[1][1],489+ Uniform: true,490+ Cells: [4]image.Point{491+ {X: 1, Y: 1},492+ {X: 2, Y: 1},493+ },494+ }); got != nil {495+ t.Fatal("expected fully open interior bridge to remain unfilled")496+ }497+}498+499+func TestBridgeFillVerticalUsesRowCrosshairOnly(t *testing.T) {500+ m := Model{cursor: pointCursor(0, 1)}501+502+ if got := bridgeFill(m, nil, game.DynamicGridBridge{503+ Kind: game.DynamicGridBridgeVertical,504+ X: 1,505+ Y: 0,506+ }); got != nil {507+ t.Fatal("expected vertical separator to ignore column-only crosshair match")508+ }509+}510+511+func TestBridgeFillHorizontalUsesColumnCrosshairOnly(t *testing.T) {512+ m := Model{cursor: pointCursor(1, 0)}513+514+ if got := bridgeFill(m, nil, game.DynamicGridBridge{515+ Kind: game.DynamicGridBridgeHorizontal,516+ X: 0,517+ Y: 1,518+ }); got != nil {519+ t.Fatal("expected horizontal separator to ignore row-only crosshair match")520+ }521+}522+523+func TestBridgeFillExtendsThroughClosedVerticalSeparator(t *testing.T) {524+ m := Model{cursor: pointCursor(0, 0)}525+526+ got := bridgeFill(m, nil, game.DynamicGridBridge{527+ Kind: game.DynamicGridBridgeVertical,528+ X: 1,529+ Y: 0,530+ })531+ if !sameColor(got, theme.Current().Surface) {532+ t.Fatal("expected closed vertical separator on cursor row to use surface background")533+ }534+}535+536+func TestBridgeFillExtendsThroughClosedHorizontalSeparator(t *testing.T) {537+ m := Model{cursor: pointCursor(0, 0)}538+539+ got := bridgeFill(m, nil, game.DynamicGridBridge{540+ Kind: game.DynamicGridBridgeHorizontal,541+ X: 0,542+ Y: 1,543+ })544+ if !sameColor(got, theme.Current().Surface) {545+ t.Fatal("expected closed horizontal separator on cursor column to use surface background")546+ }547+}548+421549 func sameColor(a, b color.Color) bool {422550 if a == nil || b == nil {423551 return a == b@@ -62,6 +62,9 @@ func gridView(m Model) string {
6262 ZoneFill: func(zone int) color.Color {6363 return bridgeBG[zone]6464 },65+ BridgeFill: func(bridge game.DynamicGridBridge) color.Color {66+ return bridgeFill(m, bridgeBG, bridge)67+ },6568 })6669 }6770 @@ -181,6 +184,115 @@ func bridgeBackgrounds(m Model, completed map[int]color.Color) map[int]color.Col
181184 return backgrounds182185 }183186 187+func bridgeFill(m Model, bridgeBG map[int]color.Color, bridge game.DynamicGridBridge) color.Color {188+ if m.solved {189+ return nil190+ }191+ if bridge.Uniform && bridgeBG[bridge.Zone] != nil {192+ return nil193+ }194+ if bridge.Count > 0 && !bridgeTouchesBorder(m.geo, bridge) {195+ return nil196+ }197+ if bridgeOnCrosshairAxis(m.cursor, bridge) {198+ return theme.Current().Surface199+ }200+ return nil201+}202+203+func bridgeOnCrosshairAxis(cursor game.Cursor, bridge game.DynamicGridBridge) bool {204+ switch bridge.Kind {205+ case game.DynamicGridBridgeVertical:206+ if bridge.Count == 0 {207+ return bridge.Y == cursor.Y208+ }209+ for i := 0; i < bridge.Count; i++ {210+ if bridge.Cells[i].Y == cursor.Y {211+ return true212+ }213+ }214+ case game.DynamicGridBridgeHorizontal:215+ if bridge.Count == 0 {216+ return bridge.X == cursor.X217+ }218+ for i := 0; i < bridge.Count; i++ {219+ if bridge.Cells[i].X == cursor.X {220+ return true221+ }222+ }223+ }224+ return false225+}226+227+func bridgeTouchesBorder(geo *geometry, bridge game.DynamicGridBridge) bool {228+ if geo == nil {229+ return false230+ }231+232+ switch bridge.Kind {233+ case game.DynamicGridBridgeVertical:234+ return cageJunctionRune(geo.cageGrid, geo.width, geo.height, bridge.X, bridge.Y) != ' ' ||235+ cageJunctionRune(geo.cageGrid, geo.width, geo.height, bridge.X, bridge.Y+1) != ' '236+ case game.DynamicGridBridgeHorizontal:237+ return cageJunctionRune(geo.cageGrid, geo.width, geo.height, bridge.X, bridge.Y) != ' ' ||238+ cageJunctionRune(geo.cageGrid, geo.width, geo.height, bridge.X+1, bridge.Y) != ' '239+ default:240+ return false241+ }242+}243+244+func cageHorizontalEdge(cageGrid [][]int, height, x, y int) bool {245+ switch {246+ case y <= 0, y >= height:247+ return true248+ default:249+ return cageGrid[y-1][x] != cageGrid[y][x]250+ }251+}252+253+func cageVerticalEdge(cageGrid [][]int, width, x, y int) bool {254+ switch {255+ case x <= 0, x >= width:256+ return true257+ default:258+ return cageGrid[y][x-1] != cageGrid[y][x]259+ }260+}261+262+func cageJunctionRune(cageGrid [][]int, width, height, x, y int) rune {263+ north := y > 0 && cageVerticalEdge(cageGrid, width, x, y-1)264+ south := y < height && cageVerticalEdge(cageGrid, width, x, y)265+ west := x > 0 && cageHorizontalEdge(cageGrid, height, x-1, y)266+ east := x < width && cageHorizontalEdge(cageGrid, height, x, y)267+268+ switch {269+ case north && south && west && east:270+ return '┼'271+ case north && south && west:272+ return '┤'273+ case north && south && east:274+ return '├'275+ case west && east && north:276+ return '┴'277+ case west && east && south:278+ return '┬'279+ case south && east:280+ return '┌'281+ case south && west:282+ return '┐'283+ case north && east:284+ return '└'285+ case north && west:286+ return '┘'287+ case north || south:288+ return '│'289+ case west || east:290+ return '─'291+ default:292+ return ' '293+ }294+}295+184296 func activeCageBridgeBackgrounds(m Model) map[int]color.Color {185297 backgrounds := make(map[int]color.Color, 1)186298 if m.geo == nil || m.width == 0 || m.height == 0 {@@ -27,6 +27,22 @@ func SeededName(seed string) string {
2727 return seed + " - " + namegen.GenerateSeeded(nameRNG)2828 }2929 30+// SeededNameForGame derives the deterministic display name for a seeded31+// puzzle locked to a specific game.32+func SeededNameForGame(seed, gameType string) string {33+ if strings.TrimSpace(gameType) == "" {34+ return SeededName(seed)35+ }36+37+ scope := game.NormalizeName(gameType)38+ nameRNG := resolve.RNGFromString("name:" + seed + ":" + scope)39+ return fmt.Sprintf("%s [%s] - %s",40+ seed,41+ gameType,42+ namegen.GenerateSeeded(nameRNG),43+ )44+}45+3046 // ImportRecord reconstructs a saved game and reapplies the record title.3147 func ImportRecord(rec *store.GameRecord) (game.Gamer, error) {3248 if rec == nil {@@ -32,6 +32,20 @@ func TestSeededNameDeterministic(t *testing.T) {
3232 }3333 }3434 35+func TestSeededNameForGameDeterministicAndDistinct(t *testing.T) {36+ seed := "zebra-seed"37+ got := SeededNameForGame(seed, "Sudoku")38+ if want := SeededNameForGame(seed, "Sudoku"); got != want {39+ t.Fatalf("SeededNameForGame returned %q then %q", got, want)40+ }41+ if got == SeededName(seed) {42+ t.Fatalf("SeededNameForGame(%q) should differ from SeededName(%q)", seed, seed)43+ }44+ if got == SeededNameForGame(seed, "Nonogram") {45+ t.Fatal("different selected games should produce different seeded names")46+ }47+}48+3549 func TestImportRecordReappliesTitle(t *testing.T) {3650 g, err := lightsout.New(3, 3)3751 if err != nil {@@ -207,6 +207,29 @@ func (p *Puzzle) encasedRectangleForClue(clueID int) (Rectangle, bool) {
207207 return rect, true208208 }209209 210+func (p *Puzzle) forcedRectangleForClue(clueID int) (Rectangle, bool) {211+ clue := p.FindClueByID(clueID)212+ if clue == nil || p.FindRectangleForClue(clueID) != nil {213+ return Rectangle{}, false214+ }215+216+ if clue.Value == 1 {217+ rect := Rectangle{218+ ClueID: clueID,219+ X: clue.X,220+ Y: clue.Y,221+ W: 1,222+ H: 1,223+ }224+ if p.ValidRectangleForClue(rect, clueID) {225+ return rect, true226+ }227+ return Rectangle{}, false228+ }229+230+ return p.encasedRectangleForClue(clueID)231+}232+210233 func (p *Puzzle) unownedRegionFrom(x, y int) [][2]int {211234 if x < 0 || x >= p.Width || y < 0 || y >= p.Height || p.CellOwner(x, y) != unownedRegionID {212235 return nil@@ -287,7 +310,7 @@ func (p *Puzzle) autoPlaceForcedRectangles() bool {
287310 for {288311 changed := false289312 for _, c := range p.Clues {290- rect, ok := p.encasedRectangleForClue(c.ID)313+ rect, ok := p.forcedRectangleForClue(c.ID)291314 if !ok {292315 continue293316 }@@ -248,6 +248,26 @@ func TestCandidateRectanglesForClue(t *testing.T) {
248248 }249249 250250 func TestAutoPlaceForcedRectangles(t *testing.T) {251+ t.Run("places single-cell clues", func(t *testing.T) {252+ p := &Puzzle{253+ Width: 3,254+ Height: 2,255+ Clues: []Clue{256+ {ID: 0, X: 0, Y: 0, Value: 1},257+ {ID: 1, X: 2, Y: 1, Value: 2},258+ },259+ }260+261+ p.autoPlaceForcedRectangles()262+263+ if len(p.Rectangles) != 1 {264+ t.Fatalf("rectangle count = %d, want 1", len(p.Rectangles))265+ }266+ if r := p.FindRectangleForClue(0); r == nil || *r != (Rectangle{ClueID: 0, X: 0, Y: 0, W: 1, H: 1}) {267+ t.Fatalf("clue 0 rectangle = %+v, want forced 1x1", r)268+ }269+ })270+251271 t.Run("does not place from global uniqueness alone", func(t *testing.T) {252272 p := forcedCascadePuzzle()253273 p.autoPlaceForcedRectangles()@@ -510,6 +530,30 @@ func TestDeleteReplacesForcedRectangle(t *testing.T) {
510530 }511531 }512532 533+func TestDeleteReplacesSingleCellForcedRectangle(t *testing.T) {534+ m := New(NewMode("Test", "test", 2, 1, 2), Puzzle{535+ Width: 2,536+ Height: 1,537+ Clues: []Clue{538+ {ID: 0, X: 0, Y: 0, Value: 1},539+ {ID: 1, X: 1, Y: 0, Value: 1},540+ },541+ }).(Model)542+543+ if len(m.puzzle.Rectangles) != 2 {544+ t.Fatalf("rectangle count = %d, want 2", len(m.puzzle.Rectangles))545+ }546+547+ m.deleteRectangle(0)548+549+ if len(m.puzzle.Rectangles) != 2 {550+ t.Fatalf("rectangle count after delete = %d, want 2", len(m.puzzle.Rectangles))551+ }552+ if r := m.puzzle.FindRectangleForClue(0); r == nil || *r != (Rectangle{ClueID: 0, X: 0, Y: 0, W: 1, H: 1}) {553+ t.Fatalf("clue 0 rectangle = %+v, want forced 1x1 replacement", r)554+ }555+}556+513557 func TestSaveJSON(t *testing.T) {514558 m := Model{515559 puzzle: Puzzle{@@ -20,6 +20,11 @@ type Model struct {
2020 keys KeyMap2121 modeTitle string2222 showFullHelp bool23+ termWidth int24+ termHeight int25+ originX int26+ originY int27+ originValid bool2328 }2429 2530 var _ game.Gamer = Model{}@@ -47,23 +52,23 @@ func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {
4752 switch msg := msg.(type) {4853 case game.HelpToggleMsg:4954 m.showFullHelp = msg.Show55+ m.originValid = false56+ case tea.WindowSizeMsg:57+ m.termWidth = msg.Width58+ m.termHeight = msg.Height59+ m.originValid = false60+ case tea.MouseClickMsg:61+ if msg.Button == tea.MouseLeft {62+ m = m.handleMouseClick(msg)63+ }5064 case tea.KeyPressMsg:5165 switch {5266 case key.Matches(msg, m.keys.PlaceZero):53- if !m.provided[m.cursor.Y][m.cursor.X] && !m.solved {54- m.grid[m.cursor.Y][m.cursor.X] = zeroCell55- m.solved = m.checkSolved()56- }67+ m.setCurrentCell(zeroCell)5768 case key.Matches(msg, m.keys.PlaceOne):58- if !m.provided[m.cursor.Y][m.cursor.X] && !m.solved {59- m.grid[m.cursor.Y][m.cursor.X] = oneCell60- m.solved = m.checkSolved()61- }69+ m.setCurrentCell(oneCell)6270 case key.Matches(msg, m.keys.Clear):63- if !m.provided[m.cursor.Y][m.cursor.X] && !m.solved {64- m.grid[m.cursor.Y][m.cursor.X] = emptyCell65- m.solved = m.checkSolved()66- }71+ m.setCurrentCell(emptyCell)6772 default:6873 m.cursor.Move(m.keys.CursorKeyMap, msg, m.size-1, m.size-1)6974 }@@ -74,7 +79,7 @@ func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {
7479 7580 func (m Model) View() string {7681 title := game.TitleBarView("Takuzu", m.modeTitle, m.solved)77- grid := gridView(m.grid, m.provided, m.cursor, m.solved)82+ grid := gridView(m)7883 if m.solved {7984 return game.ComposeGameView(title, grid)8085 }@@ -95,6 +100,7 @@ func (m Model) Reset() game.Gamer {
95100 m.grid = m.initialGrid.clone()96101 m.solved = m.checkSolved()97102 m.cursor = game.Cursor{}103+ m.originValid = false98104 return m99105 }100106 @@ -132,3 +138,46 @@ func (m Model) GetDebugInfo() string {
132138 {"Cells Filled", fmt.Sprintf("%d / %d", filled, m.size*m.size)},133139 })134140 }141+142+func (m *Model) setCurrentCell(val rune) {143+ if m.solved || m.provided[m.cursor.Y][m.cursor.X] {144+ return145+ }146+147+ wasSolved := m.solved148+ m.grid[m.cursor.Y][m.cursor.X] = val149+ m.solved = m.checkSolved()150+ if m.solved != wasSolved {151+ m.originValid = false152+ }153+}154+155+func (m *Model) cycleCurrentCell() {156+ if m.solved || m.provided[m.cursor.Y][m.cursor.X] {157+ return158+ }159+160+ switch m.grid[m.cursor.Y][m.cursor.X] {161+ case zeroCell:162+ m.setCurrentCell(oneCell)163+ case oneCell:164+ m.setCurrentCell(emptyCell)165+ default:166+ m.setCurrentCell(zeroCell)167+ }168+}169+170+func (m Model) handleMouseClick(msg tea.MouseClickMsg) Model {171+ col, row, ok := m.screenToGrid(msg.X, msg.Y)172+ if !ok {173+ return m174+ }175+176+ sameCell := col == m.cursor.X && row == m.cursor.Y177+ m.cursor.X = col178+ m.cursor.Y = row179+ if sameCell {180+ m.cycleCurrentCell()181+ }182+ return m183+}@@ -20,12 +20,12 @@ func (printAdapter) BuildPDFPayload(save []byte) (any, error) {
2020 func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error {2121 switch data := payload.(type) {2222 case *pdfexport.TakuzuData:23- renderTakuzuPage(pdf, data)23+ RenderTakuzuPDFBody(pdf, data)2424 }2525 return nil2626 }2727 28-func renderTakuzuPage(pdf *fpdf.Fpdf, data *pdfexport.TakuzuData) {28+func RenderTakuzuPDFBody(pdf *fpdf.Fpdf, data *pdfexport.TakuzuData) {2929 if data == nil || data.Size <= 0 {3030 return3131 }@@ -80,6 +80,8 @@ func renderTakuzuPage(pdf *fpdf.Fpdf, data *pdfexport.TakuzuData) {
8080 }8181 }8282 83+ drawTakuzuRelations(pdf, data, startX, startY, cellSize)84+8385 pdf.SetDrawColor(35, 35, 35)8486 pdf.SetLineWidth(pdfexport.OuterBorderLineMM)8587 pdf.Rect(startX, startY, blockW, blockH, "D")@@ -112,6 +114,52 @@ func renderTakuzuPage(pdf *fpdf.Fpdf, data *pdfexport.TakuzuData) {
112114 )113115 }114116 117+func drawTakuzuRelations(pdf *fpdf.Fpdf, data *pdfexport.TakuzuData, startX, startY, cellSize float64) {118+ if data == nil {119+ return120+ }121+122+ pdf.SetTextColor(95, 95, 95)123+ fontSize := takuzuRelationFontSize(cellSize, data.Size)124+ pdf.SetFont(pdfexport.SansFontFamily, "B", fontSize)125+ pdf.SetFillColor(255, 255, 255)126+127+ for y, row := range data.HorizontalRelations {128+ for x, value := range row {129+ if strings.TrimSpace(value) == "" {130+ continue131+ }132+133+ centerX := startX + float64(x+1)*cellSize134+ centerY := startY + float64(y)*cellSize + cellSize/2135+ drawTakuzuRelation(pdf, centerX, centerY, cellSize, fontSize, value)136+ }137+ }138+139+ for y, row := range data.VerticalRelations {140+ for x, value := range row {141+ if strings.TrimSpace(value) == "" {142+ continue143+ }144+145+ centerX := startX + float64(x)*cellSize + cellSize/2146+ centerY := startY + float64(y+1)*cellSize147+ drawTakuzuRelation(pdf, centerX, centerY, cellSize, fontSize, value)148+ }149+ }150+}151+152+func drawTakuzuRelation(pdf *fpdf.Fpdf, centerX, centerY, cellSize, fontSize float64, value string) {153+ boxSize := takuzuRelationBackdropSize(cellSize, fontSize)154+ left := centerX - boxSize/2155+ top := centerY - boxSize/2156+ lineH := fontSize * 0.9157+158+ pdf.Rect(left, top, boxSize, boxSize, "F")159+ pdf.SetXY(left, centerY-lineH/2)160+ pdf.CellFormat(boxSize, lineH, value, "", 0, "C", false, 0, "")161+}162+115163 func drawTakuzuGiven(pdf *fpdf.Fpdf, x, y, cellSize float64, size int, text string) {116164 fontSize := takuzuGivenFontSize(cellSize, size)117165 @@ -133,6 +181,21 @@ func takuzuGivenFontSize(cellSize float64, size int) float64 {
133181 return pdfexport.ClampStandardCellFontSize(fontSize)134182 }135183 184+func takuzuRelationFontSize(cellSize float64, size int) float64 {185+ fontSize := cellSize * 0.58186+ if size >= 12 {187+ fontSize *= 0.97188+ }189+ if fontSize < 6.0 {190+ return 6.0191+ }192+ return pdfexport.ClampStandardCellFontSize(fontSize)193+}194+195+func takuzuRelationBackdropSize(cellSize, fontSize float64) float64 {196+ return fontSize + cellSize*0.12197+}198+136199 func init() {137200 game.RegisterPrintAdapter(printAdapter{})138201 }@@ -21,6 +21,7 @@ every cell is filled and all three rules are satisfied.
2121 | Key | Action |2222 |-----|--------|2323 | Arrow keys / WASD / hjkl | Move cursor |24+| Mouse left-click | Move to a cell, or cycle the current editable cell |2425 | `z` / `0` | Place ● |2526 | `x` / `1` | Place ○ |2627 | `Backspace` | Clear cell |@@ -519,6 +519,40 @@ func rowFilled(g grid, y, size int) bool {
519519 return true520520 }521521 522+func GenerateCompleteGrid(size int) [][]rune {523+ return generateComplete(size).clone()524+}525+526+func GenerateCompleteGridSeeded(size int, rng *rand.Rand) [][]rune {527+ return generateCompleteSeeded(size, rng).clone()528+}529+530+func GeneratePuzzleFromComplete(complete [][]rune, size int, prefilled float64) ([][]rune, [][]bool) {531+ puzzle, provided := generatePuzzle(grid(complete).clone(), size, prefilled)532+ return puzzle.clone(), provided533+}534+535+func GeneratePuzzleFromCompleteSeeded(complete [][]rune, size int, prefilled float64, rng *rand.Rand) ([][]rune, [][]bool) {536+ puzzle, provided := generatePuzzleSeeded(grid(complete).clone(), size, prefilled, rng)537+ return puzzle.clone(), provided538+}539+540+func CountSolutionsGrid(g [][]rune, size, limit int) int {541+ return countSolutions(grid(g).clone(), size, limit)542+}543+544+func CheckConstraintsGrid(g [][]rune, size int) bool {545+ return checkConstraints(grid(g), size)546+}547+548+func HasUniqueLinesGrid(g [][]rune, size int) bool {549+ return hasUniqueLines(grid(g), size)550+}551+552+func CanPlaceInGrid(g [][]rune, size, x, y int, val rune) bool {553+ return canPlace(grid(g), size, x, y, val)554+}555+522556 func colFilled(g grid, x, size int) bool {523557 for y := range size {524558 if g[y][x] == emptyCell {@@ -10,6 +10,12 @@ const (
1010 oneCell rune = '1'1111 )1212 13+const (14+ EmptyCell = emptyCell15+ ZeroCell = zeroCell16+ OneCell = oneCell17+)18+1319 type (1420 grid [][]rune1521 state string@@ -54,3 +60,11 @@ func (g grid) clone() grid {
5460 }5561 return c5662 }63+64+func CloneGrid(g [][]rune) [][]rune {65+ return grid(g).clone()66+}67+68+func CreateEmptyGrid(size int) [][]rune {69+ return newGrid(createEmptyState(size)).clone()70+}@@ -16,6 +16,7 @@ solved when every cell is filled and all three rules are satisfied.
1616 | Key | Action |1717 |-----|--------|1818 | `Arrows` / `wasd` / `hjkl` | Move cursor |19+| `Mouse left-click` | Move to a cell, or cycle the current editable cell |1920 | `z` / `0` | Place ● (filled) |2021 | `x` / `1` | Place ○ (open) |2122 | `Backspace` | Clear cell |@@ -0,0 +1,35 @@
1+package takuzu2+3+import "github.com/FelineStateMachine/puzzletea/game"4+5+func (m *Model) screenToGrid(screenX, screenY int) (col, row int, ok bool) {6+ ox, oy := m.cachedGridOrigin()7+ return game.DynamicGridScreenToCell(8+ game.DynamicGridMetrics{9+ Width: m.size,10+ Height: m.size,11+ CellWidth: cellWidth,12+ },13+ ox,14+ oy,15+ screenX,16+ screenY,17+ false,18+ )19+}20+21+func (m *Model) cachedGridOrigin() (x, y int) {22+ if m.originValid {23+ return m.originX, m.originY24+ }25+ x, y = m.gridOrigin()26+ m.originX, m.originY = x, y27+ m.originValid = true28+ return x, y29+}30+31+func (m *Model) gridOrigin() (x, y int) {32+ title := game.TitleBarView("Takuzu", m.modeTitle, m.solved)33+ grid := gridView(*m)34+ return game.DynamicGridOrigin(m.termWidth, m.termHeight, m.View(), title, grid)35+}@@ -42,3 +42,78 @@ func TestTakuzuGivenFontSize(t *testing.T) {
4242 })4343 }4444 }45+46+func TestTakuzuRelationFontSize(t *testing.T) {47+ tests := []struct {48+ name string49+ cellSize float6450+ size int51+ wantMin float6452+ wantMax float6453+ }{54+ {55+ name: "small cells keep a larger minimum for relation clues",56+ cellSize: 8.0,57+ size: 14,58+ wantMin: 6.0,59+ wantMax: 6.0,60+ },61+ {62+ name: "10x10 clues scale above the old minimum",63+ cellSize: 11.0,64+ size: 10,65+ wantMin: 6.3,66+ wantMax: 6.5,67+ },68+ {69+ name: "large cells still respect the shared cap",70+ cellSize: 16.0,71+ size: 6,72+ wantMin: 8.2,73+ wantMax: 8.2,74+ },75+ }76+77+ for _, tt := range tests {78+ t.Run(tt.name, func(t *testing.T) {79+ got := takuzuRelationFontSize(tt.cellSize, tt.size)80+ if got < tt.wantMin || got > tt.wantMax {81+ t.Fatalf("font size = %.3f, want %.3f..%.3f", got, tt.wantMin, tt.wantMax)82+ }83+ })84+ }85+}86+87+func TestTakuzuRelationBackdropSize(t *testing.T) {88+ tests := []struct {89+ name string90+ cellSize float6491+ fontSize float6492+ wantMin float6493+ wantMax float6494+ }{95+ {96+ name: "adds padding beyond the glyph size",97+ cellSize: 11.0,98+ fontSize: 6.4,99+ wantMin: 7.7,100+ wantMax: 7.8,101+ },102+ {103+ name: "still provides a readable knockout on tighter boards",104+ cellSize: 8.0,105+ fontSize: 6.0,106+ wantMin: 6.9,107+ wantMax: 7.0,108+ },109+ }110+111+ for _, tt := range tests {112+ t.Run(tt.name, func(t *testing.T) {113+ got := takuzuRelationBackdropSize(tt.cellSize, tt.fontSize)114+ if got < tt.wantMin || got > tt.wantMax {115+ t.Fatalf("backdrop size = %.3f, want %.3f..%.3f", got, tt.wantMin, tt.wantMax)116+ }117+ })118+ }119+}@@ -1,38 +1,34 @@
11 package takuzu22 33 import (4+ "image/color"5+46 "charm.land/lipgloss/v2"57 "github.com/FelineStateMachine/puzzletea/game"68 "github.com/FelineStateMachine/puzzletea/theme"79 )810 9-const cellWidth = 311+const cellWidth = game.DynamicGridCellWidth1012 1113 var renderRuneMap = map[rune]string{12- zeroCell: " \u25cf ",13- oneCell: " \u25cb ",14- emptyCell: " \u00b7 ",14+ zeroCell: " ● ",15+ oneCell: " ○ ",16+ emptyCell: " · ",1517 }1618 1719 func zeroStyle() lipgloss.Style {1820 p := theme.Current()19- return lipgloss.NewStyle().20- Foreground(p.Accent).21- Background(p.BG)21+ return lipgloss.NewStyle().Foreground(p.Accent).Background(p.BG)2222 }2323 2424 func oneStyle() lipgloss.Style {2525 p := theme.Current()26- return lipgloss.NewStyle().27- Foreground(p.Secondary).28- Background(p.BG)26+ return lipgloss.NewStyle().Foreground(p.Secondary).Background(p.BG)2927 }3028 3129 func emptyStyle() lipgloss.Style {3230 p := theme.Current()33- return lipgloss.NewStyle().34- Foreground(p.TextDim).35- Background(p.BG)31+ return lipgloss.NewStyle().Foreground(p.TextDim).Background(p.BG)3632 }3733 3834 func renderStyleMap() map[rune]lipgloss.Style {@@ -46,74 +42,72 @@ func renderStyleMap() map[rune]lipgloss.Style {
4642 func cellView(val rune, isProvided, isCursor, inCursorRow, inCursorCol, solved bool) string {4743 p := theme.Current()4844 styles := renderStyleMap()49- s, ok := styles[val]45+ style, ok := styles[val]5046 if !ok {51- s = emptyStyle()47+ style = emptyStyle()5248 }5349 5450 if isProvided && val != emptyCell {55- s = s.Bold(true)51+ style = style.Bold(true)5652 }5753 58- r, ok := renderRuneMap[val]54+ text, ok := renderRuneMap[val]5955 if !ok {60- r = renderRuneMap[emptyCell]56+ text = renderRuneMap[emptyCell]6157 }6258 63- if isCursor && solved {64- s = game.CursorSolvedStyle()65- r = game.CursorLeft + string([]rune(r)[1]) + game.CursorRight66- } else if isCursor {67- s = game.CursorStyle()68- r = game.CursorLeft + string([]rune(r)[1]) + game.CursorRight69- } else if solved {70- s = s.Foreground(p.SolvedFG).Background(p.SuccessBG)71- } else if inCursorRow || inCursorCol {72- s = s.Background(p.Surface)59+ switch {60+ case isCursor && solved:61+ style = game.CursorSolvedStyle()62+ text = game.CursorLeft + string([]rune(text)[1]) + game.CursorRight63+ case isCursor:64+ style = game.CursorStyle()65+ text = game.CursorLeft + string([]rune(text)[1]) + game.CursorRight66+ case solved:67+ style = style.Foreground(p.SolvedFG).Background(p.SuccessBG)68+ case inCursorRow || inCursorCol:69+ style = style.Background(p.Surface)7370 }7471 75- return s.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(r)72+ return style.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(text)7673 }7774 78-func gridView(g grid, provided [][]bool, c game.Cursor, solved bool) string {79- colors := game.DefaultBorderColors()80-81- w := 082- if len(g) > 0 {83- w = len(g[0])84- }85-86- var rows []string87- for y, row := range g {88- var rowCells []string89- inCursorRow := y == c.Y90-91- // Left border segment for this row.92- rowCells = append(rowCells, game.BorderChar("\u2502", colors, solved, !solved && inCursorRow))93-94- for x, val := range row {95- isCursor := x == c.X && y == c.Y96- inCursorCol := x == c.X97- cell := cellView(val, provided[y][x], isCursor, inCursorRow, inCursorCol, solved)98- rowCells = append(rowCells, cell)99- }100-101- // Right border segment for this row.102- rowCells = append(rowCells, game.BorderChar("\u2502", colors, solved, !solved && inCursorRow))103-104- rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, rowCells...))105- }106-107- // Build top and bottom border rows with per-column crosshair highlighting.108- topRow := game.HBorderRow(w, c.X, cellWidth, "\u256d", "\u256e", colors, solved)109- botRow := game.HBorderRow(w, c.X, cellWidth, "\u2570", "\u256f", colors, solved)110-111- return lipgloss.JoinVertical(lipgloss.Left, topRow, lipgloss.JoinVertical(lipgloss.Left, rows...), botRow)75+func gridView(m Model) string {76+ return game.RenderDynamicGrid(game.DynamicGridSpec{77+ Width: m.size,78+ Height: m.size,79+ Solved: m.solved,80+ Cell: func(x, y int) string {81+ return cellView(82+ m.grid[y][x],83+ m.provided[y][x],84+ x == m.cursor.X && y == m.cursor.Y,85+ y == m.cursor.Y,86+ x == m.cursor.X,87+ m.solved,88+ )89+ },90+ ZoneAt: func(_, _ int) int {91+ return 092+ },93+ BridgeFill: func(bridge game.DynamicGridBridge) color.Color {94+ if m.solved {95+ return theme.Current().SuccessBG96+ }97+ for i := 0; i < bridge.Count; i++ {98+ cell := bridge.Cells[i]99+ if cell.X == m.cursor.X || cell.Y == m.cursor.Y {100+ return theme.Current().Surface101+ }102+ }103+ return nil104+ },105+ })112106 }113107 114108 func statusBarView(showFullHelp bool) string {115109 if showFullHelp {116- return game.StatusBarStyle().Render("arrows/wasd: move z: \u25cf x: \u25cb bkspc: clear ctrl+n: menu ctrl+r: reset ctrl+h: help")110+ return game.StatusBarStyle().Render("arrows/wasd: move mouse: click/cycle z: ● x: ○ bkspc: clear ctrl+n: menu ctrl+r: reset ctrl+h: help")117111 }118- return game.StatusBarStyle().Render("z: \u25cf x: \u25cb bkspc: clear")112+ return game.StatusBarStyle().Render("mouse: click/cycle z: ● x: ○")119113 }@@ -5,6 +5,7 @@ import (
55 "math/rand/v2"66 "testing"77 8+ tea "charm.land/bubbletea/v2"89 "github.com/FelineStateMachine/puzzletea/game"910 )1011 @@ -995,3 +996,89 @@ func TestGeneratePuzzle_AllModes_SeededRegression(t *testing.T) {
995996 })996997 }997998 }999+1000+func TestMouseClickMovesCursor(t *testing.T) {1001+ m := testMouseModel()1002+1003+ x, y := takuzuCellScreenCoords(&m, 2, 1)1004+ next, _ := m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})1005+ got := next.(Model)1006+1007+ if got.cursor.X != 2 || got.cursor.Y != 1 {1008+ t.Fatalf("cursor = (%d,%d), want (2,1)", got.cursor.X, got.cursor.Y)1009+ }1010+ if got.grid[1][2] != emptyCell {1011+ t.Fatalf("clicked destination cell changed to %q, want unchanged empty", got.grid[1][2])1012+ }1013+}1014+1015+func TestMouseClickSameCellCyclesEditableCell(t *testing.T) {1016+ m := testMouseModel()1017+ m.cursor.X, m.cursor.Y = 1, 11018+1019+ x, y := takuzuCellScreenCoords(&m, 1, 1)1020+1021+ next, _ := m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})1022+ got := next.(Model)1023+ if got.grid[1][1] != zeroCell {1024+ t.Fatalf("first click = %q, want %q", got.grid[1][1], zeroCell)1025+ }1026+1027+ next, _ = got.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})1028+ got = next.(Model)1029+ if got.grid[1][1] != oneCell {1030+ t.Fatalf("second click = %q, want %q", got.grid[1][1], oneCell)1031+ }1032+1033+ next, _ = got.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})1034+ got = next.(Model)1035+ if got.grid[1][1] != emptyCell {1036+ t.Fatalf("third click = %q, want %q", got.grid[1][1], emptyCell)1037+ }1038+}1039+1040+func TestMouseClickSameCellDoesNotCycleProvidedCell(t *testing.T) {1041+ m := testMouseModel()1042+ m.cursor.X, m.cursor.Y = 0, 01043+1044+ x, y := takuzuCellScreenCoords(&m, 0, 0)1045+ next, _ := m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})1046+ got := next.(Model)1047+1048+ if got.grid[0][0] != zeroCell {1049+ t.Fatalf("provided cell changed to %q, want %q", got.grid[0][0], zeroCell)1050+ }1051+}1052+1053+func testMouseModel() Model {1054+ return Model{1055+ size: 4,1056+ grid: grid{1057+ []rune("0..1"),1058+ []rune("...."),1059+ []rune("...."),1060+ []rune("1..0"),1061+ },1062+ initialGrid: grid{1063+ []rune("0..1"),1064+ []rune("...."),1065+ []rune("...."),1066+ []rune("1..0"),1067+ },1068+ provided: [][]bool{1069+ {true, false, false, true},1070+ {false, false, false, false},1071+ {false, false, false, false},1072+ {true, false, false, true},1073+ },1074+ keys: DefaultKeyMap,1075+ modeTitle: "Test",1076+ termWidth: 120,1077+ termHeight: 40,1078+ }1079+}1080+1081+func takuzuCellScreenCoords(m *Model, col, row int) (int, int) {1082+ ox, oy := m.gridOrigin()1083+ return ox + col*(cellWidth+1), oy + row*21084+}@@ -0,0 +1,107 @@
1+package takuzuplus2+3+import (4+ "encoding/json"5+ "fmt"6+ "strings"7+8+ "github.com/FelineStateMachine/puzzletea/game"9+)10+11+type Save struct {12+ Size int `json:"size"`13+ State string `json:"state"`14+ Provided string `json:"provided"`15+ ModeTitle string `json:"mode_title"`16+ HorizontalRelations string `json:"horizontal_relations"`17+ VerticalRelations string `json:"vertical_relations"`18+}19+20+func (m Model) GetSave() ([]byte, error) {21+ save := Save{22+ Size: m.size,23+ State: m.grid.String(),24+ Provided: serializeProvided(m.provided),25+ ModeTitle: m.modeTitle,26+ HorizontalRelations: serializeRuneRows(m.relations.horizontal),27+ VerticalRelations: serializeRuneRows(m.relations.vertical),28+ }29+ data, err := json.Marshal(save)30+ if err != nil {31+ return nil, fmt.Errorf("failed to marshal takuzu+ save: %w", err)32+ }33+ return data, nil34+}35+36+func ImportModel(data []byte) (*Model, error) {37+ var s Save38+ if err := json.Unmarshal(data, &s); err != nil {39+ return nil, fmt.Errorf("failed to unmarshal takuzu+ save: %w", err)40+ }41+ if s.Size <= 0 {42+ return nil, fmt.Errorf("invalid grid size in save: %d", s.Size)43+ }44+45+ g := newGridFromState(s.State)46+ provided := deserializeProvided(s.Provided, s.Size)47+ rels := relations{48+ horizontal: deserializeRuneRows(s.HorizontalRelations, s.Size, max(s.Size-1, 0)),49+ vertical: deserializeRuneRows(s.VerticalRelations, max(s.Size-1, 0), s.Size),50+ }51+52+ initial := g.clone()53+ for y := range s.Size {54+ for x := range s.Size {55+ if !provided[y][x] {56+ initial[y][x] = emptyCell57+ }58+ }59+ }60+61+ m := &Model{62+ size: s.Size,63+ grid: g,64+ initialGrid: initial,65+ provided: provided,66+ relations: rels,67+ cursor: game.Cursor{X: 0, Y: 0},68+ keys: DefaultKeyMap,69+ modeTitle: s.ModeTitle,70+ }71+ m.solved = m.checkSolved()72+ return m, nil73+}74+75+func serializeProvided(p [][]bool) string {76+ var b strings.Builder77+ for y, row := range p {78+ for _, v := range row {79+ if v {80+ b.WriteByte('#')81+ } else {82+ b.WriteByte('.')83+ }84+ }85+ if y < len(p)-1 {86+ b.WriteByte('\n')87+ }88+ }89+ return b.String()90+}91+92+func deserializeProvided(s string, size int) [][]bool {93+ rows := strings.Split(s, "\n")94+ p := make([][]bool, size)95+ for y := range size {96+ p[y] = make([]bool, size)97+ if y >= len(rows) {98+ continue99+ }100+ for x := range size {101+ if x < len(rows[y]) && rows[y][x] == '#' {102+ p[y][x] = true103+ }104+ }105+ }106+ return p107+}@@ -0,0 +1,73 @@
1+package takuzuplus2+3+import (4+ _ "embed"5+ "math/rand/v2"6+7+ "charm.land/bubbles/v2/list"8+ "github.com/FelineStateMachine/puzzletea/game"9+)10+11+//go:embed help.md12+var HelpContent string13+14+type TakuzuPlusMode struct {15+ game.BaseMode16+ Size int17+ Prefilled float6418+ profile relationProfile19+}20+21+var (22+ _ game.Mode = TakuzuPlusMode{}23+ _ game.Spawner = TakuzuPlusMode{}24+ _ game.SeededSpawner = TakuzuPlusMode{}25+)26+27+func NewMode(title, desc string, size int, prefilled float64, profile relationProfile) TakuzuPlusMode {28+ return TakuzuPlusMode{29+ BaseMode: game.NewBaseMode(title, desc),30+ Size: size,31+ Prefilled: prefilled,32+ profile: profile,33+ }34+}35+36+func (t TakuzuPlusMode) Spawn() (game.Gamer, error) {37+ complete := generateComplete(t.Size)38+ puzzle, provided, rels := generatePuzzle(complete, t.Size, t.Prefilled, t.profile)39+ return New(t, puzzle, provided, rels)40+}41+42+func (t TakuzuPlusMode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) {43+ complete := generateCompleteSeeded(t.Size, rng)44+ puzzle, provided, rels := generatePuzzleSeeded(complete, t.Size, t.Prefilled, t.profile, rng)45+ return New(t, puzzle, provided, rels)46+}47+48+var modeProfiles = relationProfiles()49+50+var Modes = []list.Item{51+ NewMode("Beginner", "6×6 grid, relation clues ease early logic.", 6, 0.50, modeProfiles[0]),52+ NewMode("Easy", "6×6 grid with additive = and x clues.", 6, 0.40, modeProfiles[1]),53+ NewMode("Medium", "8×8 grid, mixed Takuzu and relation deductions.", 8, 0.40, modeProfiles[2]),54+ NewMode("Tricky", "10×10 grid, uniqueness and relation clues interact.", 10, 0.38, modeProfiles[3]),55+ NewMode("Hard", "10×10 grid, longer deduction chains with relation clues.", 10, 0.32, modeProfiles[4]),56+ NewMode("Very Hard", "12×12 grid, sparse givens plus relation clues.", 12, 0.30, modeProfiles[5]),57+ NewMode("Extreme", "14×14 grid, maximum size with additive relation clues.", 14, 0.28, modeProfiles[6]),58+}59+60+var DailyModes = []list.Item{61+ Modes[2],62+ Modes[3],63+}64+65+var Definition = game.Definition{66+ Name: "Takuzu+",67+ Description: "Fill grid with ● and ○ using cell and relation clues.",68+ Aliases: []string{"takuzu plus", "binario+", "binario plus"},69+ Modes: Modes,70+ DailyModes: DailyModes,71+ Help: HelpContent,72+ Import: func(data []byte) (game.Gamer, error) { return ImportModel(data) },73+}@@ -0,0 +1,186 @@
1+// Package takuzuplus implements the Takuzu+ puzzle game.2+package takuzuplus3+4+import (5+ "fmt"6+7+ "charm.land/bubbles/v2/key"8+ tea "charm.land/bubbletea/v2"9+ "github.com/FelineStateMachine/puzzletea/game"10+ "github.com/FelineStateMachine/puzzletea/takuzu"11+)12+13+type Model struct {14+ size int15+ grid grid16+ initialGrid grid17+ provided [][]bool18+ relations relations19+ cursor game.Cursor20+ solved bool21+ keys KeyMap22+ modeTitle string23+ showFullHelp bool24+ termWidth int25+ termHeight int26+ originX int27+ originY int28+ originValid bool29+}30+31+var _ game.Gamer = Model{}32+33+func New(mode TakuzuPlusMode, puzzle grid, provided [][]bool, rels relations) (game.Gamer, error) {34+ m := Model{35+ size: mode.Size,36+ grid: puzzle,37+ initialGrid: puzzle.clone(),38+ provided: provided,39+ relations: rels,40+ cursor: game.Cursor{X: 0, Y: 0},41+ keys: DefaultKeyMap,42+ modeTitle: mode.Title(),43+ }44+ m.solved = m.checkSolved()45+ return m, nil46+}47+48+func (m Model) Init() tea.Cmd {49+ return nil50+}51+52+func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {53+ switch msg := msg.(type) {54+ case game.HelpToggleMsg:55+ m.showFullHelp = msg.Show56+ m.originValid = false57+ case tea.WindowSizeMsg:58+ m.termWidth = msg.Width59+ m.termHeight = msg.Height60+ m.originValid = false61+ case tea.MouseClickMsg:62+ if msg.Button == tea.MouseLeft {63+ m = m.handleMouseClick(msg)64+ }65+ case tea.KeyPressMsg:66+ switch {67+ case key.Matches(msg, m.keys.PlaceZero):68+ m.setCurrentCell(zeroCell)69+ case key.Matches(msg, m.keys.PlaceOne):70+ m.setCurrentCell(oneCell)71+ case key.Matches(msg, m.keys.Clear):72+ m.setCurrentCell(emptyCell)73+ default:74+ m.cursor.Move(m.keys.CursorKeyMap, msg, m.size-1, m.size-1)75+ }76+ }77+ m.updateKeyBindings()78+ return m, nil79+}80+81+func (m Model) View() string {82+ title := game.TitleBarView("Takuzu+", m.modeTitle, m.solved)83+ grid := gridView(m)84+ if m.solved {85+ return game.ComposeGameView(title, grid)86+ }87+ status := statusBarView(m.showFullHelp)88+ return game.ComposeGameViewRows(title, grid, game.StableRow(status, statusBarView(false), statusBarView(true)))89+}90+91+func (m Model) SetTitle(t string) game.Gamer {92+ m.modeTitle = t93+ return m94+}95+96+func (m Model) IsSolved() bool {97+ return m.solved98+}99+100+func (m Model) Reset() game.Gamer {101+ m.grid = m.initialGrid.clone()102+ m.solved = m.checkSolved()103+ m.cursor = game.Cursor{}104+ m.originValid = false105+ return m106+}107+108+func (m Model) checkSolved() bool {109+ for y := range m.size {110+ for x := range m.size {111+ if m.grid[y][x] == emptyCell {112+ return false113+ }114+ }115+ }116+ return takuzu.CheckConstraintsGrid(m.grid, m.size) &&117+ takuzu.HasUniqueLinesGrid(m.grid, m.size) &&118+ checkRelations(m.grid, m.relations)119+}120+121+func (m Model) GetDebugInfo() string {122+ status := "In Progress"123+ if m.solved {124+ status = "Solved"125+ }126+127+ filled := 0128+ for y := range m.size {129+ for x := range m.size {130+ if m.grid[y][x] != emptyCell {131+ filled++132+ }133+ }134+ }135+136+ return game.DebugHeader("Takuzu+", [][2]string{137+ {"Status", status},138+ {"Cursor", fmt.Sprintf("(%d, %d)", m.cursor.X, m.cursor.Y)},139+ {"Grid Size", fmt.Sprintf("%d×%d", m.size, m.size)},140+ {"Cells Filled", fmt.Sprintf("%d / %d", filled, m.size*m.size)},141+ {"Relation Clues", fmt.Sprintf("%d", countRelations(m.relations))},142+ })143+}144+145+func (m *Model) setCurrentCell(val rune) {146+ if m.solved || m.provided[m.cursor.Y][m.cursor.X] {147+ return148+ }149+150+ wasSolved := m.solved151+ m.grid[m.cursor.Y][m.cursor.X] = val152+ m.solved = m.checkSolved()153+ if m.solved != wasSolved {154+ m.originValid = false155+ }156+}157+158+func (m *Model) cycleCurrentCell() {159+ if m.solved || m.provided[m.cursor.Y][m.cursor.X] {160+ return161+ }162+163+ switch m.grid[m.cursor.Y][m.cursor.X] {164+ case zeroCell:165+ m.setCurrentCell(oneCell)166+ case oneCell:167+ m.setCurrentCell(emptyCell)168+ default:169+ m.setCurrentCell(zeroCell)170+ }171+}172+173+func (m Model) handleMouseClick(msg tea.MouseClickMsg) Model {174+ col, row, ok := m.screenToGrid(msg.X, msg.Y)175+ if !ok {176+ return m177+ }178+179+ sameCell := col == m.cursor.X && row == m.cursor.Y180+ m.cursor.X = col181+ m.cursor.Y = row182+ if sameCell {183+ m.cycleCurrentCell()184+ }185+ return m186+}@@ -0,0 +1,29 @@
1+package takuzuplus2+3+import (4+ "codeberg.org/go-pdf/fpdf"5+ "github.com/FelineStateMachine/puzzletea/game"6+ "github.com/FelineStateMachine/puzzletea/pdfexport"7+ "github.com/FelineStateMachine/puzzletea/takuzu"8+)9+10+type printAdapter struct{}11+12+func (printAdapter) CanonicalGameType() string { return "Takuzu+" }13+func (printAdapter) Aliases() []string { return []string{"takuzu plus", "binario+", "binario plus"} }14+15+func (printAdapter) BuildPDFPayload(save []byte) (any, error) {16+ return pdfexport.ParseTakuzuPlusPrintData(save)17+}18+19+func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error {20+ switch data := payload.(type) {21+ case *pdfexport.TakuzuData:22+ takuzu.RenderTakuzuPDFBody(pdf, data)23+ }24+ return nil25+}26+27+func init() {28+ game.RegisterPrintAdapter(printAdapter{})29+}@@ -0,0 +1,519 @@
1+package takuzuplus2+3+import (4+ "cmp"5+ "image"6+ "math"7+ "math/rand/v2"8+ "slices"9+10+ "github.com/FelineStateMachine/puzzletea/takuzu"11+)12+13+type cellPos struct{ x, y int }14+15+type relationCandidate struct {16+ horizontal bool17+ x, y int18+}19+20+type relationChoice struct {21+ candidate relationCandidate22+ remove cellPos23+ additive bool24+ endpointClass int25+ symbol rune26+ score int27+}28+29+func generateComplete(size int) grid {30+ return grid(takuzu.GenerateCompleteGrid(size))31+}32+33+func generateCompleteSeeded(size int, rng *rand.Rand) grid {34+ return grid(takuzu.GenerateCompleteGridSeeded(size, rng))35+}36+37+func generatePuzzle(complete grid, size int, prefilled float64, profile relationProfile) (grid, [][]bool, relations) {38+ rng := rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))39+ return generatePuzzleSeeded(complete, size, prefilled, profile, rng)40+}41+42+func generatePuzzleSeeded(complete grid, size int, prefilled float64, profile relationProfile, rng *rand.Rand) (grid, [][]bool, relations) {43+ maxAttempts := 1044+ switch {45+ case size >= 14:46+ maxAttempts = 347+ case size >= 12:48+ maxAttempts = 649+ }50+51+ bestPenalty := math.MaxInt52+ var bestPuzzle grid53+ var bestProvided [][]bool54+ var bestRelations relations55+56+ for attempt := 0; attempt < maxAttempts; attempt++ {57+ desiredRelations := profile.targetCount(rng)58+ puzzle, provided, rels := generatePuzzleAttempt(complete, size, prefilled, profile, desiredRelations, rng)59+ penalty := relationPenalty(profile, size, rels, provided)60+ if penalty < bestPenalty {61+ bestPenalty = penalty62+ bestPuzzle = puzzle.clone()63+ bestProvided = cloneProvided(provided)64+ bestRelations = rels.clone()65+ }66+ if penalty == 0 {67+ return puzzle, provided, rels68+ }69+ }70+71+ return bestPuzzle, bestProvided, bestRelations72+}73+74+func generatePuzzleAttempt(75+ complete grid,76+ size int,77+ prefilled float64,78+ profile relationProfile,79+ desiredRelations int,80+ rng *rand.Rand,81+) (grid, [][]bool, relations) {82+ puzzle := complete.clone()83+ provided := make([][]bool, size)84+ for y := range size {85+ provided[y] = make([]bool, size)86+ for x := range size {87+ provided[y][x] = true88+ }89+ }90+ rels := newRelations(size)91+92+ targetFilled := int(prefilled * float64(size*size))93+ filled := size * size94+95+ for filled > targetFilled {96+ progress := false97+ cells := providedCells(provided)98+ rng.Shuffle(len(cells), func(i, j int) {99+ cells[i], cells[j] = cells[j], cells[i]100+ })101+102+ for _, cell := range cells {103+ if !provided[cell.y][cell.x] || filled <= targetFilled {104+ continue105+ }106+107+ remainingRemovals := filled - targetFilled108+ relationShortage := desiredRelations - countRelations(rels)109+ preferRelation := countRelations(rels) < profile.MaxRelations &&110+ shouldPreferRelationRemoval(relationShortage, remainingRemovals, rng)111+112+ if preferRelation {113+ if tryRelationRemoval(puzzle, complete, provided, rels, size, profile, cell, rng) {114+ filled--115+ progress = true116+ continue117+ }118+ }119+120+ if tryDirectRemoval(puzzle, provided, rels, size, cell) {121+ filled--122+ progress = true123+ continue124+ }125+126+ if !preferRelation && countRelations(rels) < profile.MaxRelations &&127+ tryRelationRemoval(puzzle, complete, provided, rels, size, profile, cell, rng) {128+ filled--129+ progress = true130+ }131+ }132+133+ if !progress {134+ break135+ }136+ }137+138+ for countRelations(rels) < desiredRelations {139+ if !tryPostPassRelationRemoval(puzzle, complete, provided, rels, size, profile, rng) {140+ break141+ }142+ filled--143+ }144+145+ for countRelations(rels) < profile.MinRelations || additiveRelationsNeeded(profile, size, rels, provided) {146+ if countRelations(rels) >= profile.MaxRelations {147+ break148+ }149+ if !tryAdditiveRelation(complete, provided, rels, size, profile, rng) {150+ break151+ }152+ }153+154+ return puzzle, provided, rels155+}156+157+func shouldPreferRelationRemoval(relationShortage, remainingRemovals int, rng *rand.Rand) bool {158+ if relationShortage <= 0 || remainingRemovals <= 0 {159+ return false160+ }161+ if remainingRemovals <= relationShortage {162+ return true163+ }164+ return rng.IntN(remainingRemovals) < relationShortage165+}166+167+func tryDirectRemoval(puzzle grid, provided [][]bool, rels relations, size int, cell cellPos) bool {168+ saved := puzzle[cell.y][cell.x]169+ puzzle[cell.y][cell.x] = emptyCell170+ if countSolutions(puzzle, size, 2, rels) == 1 {171+ provided[cell.y][cell.x] = false172+ return true173+ }174+ puzzle[cell.y][cell.x] = saved175+ return false176+}177+178+func tryRelationRemoval(179+ puzzle, complete grid,180+ provided [][]bool,181+ rels relations,182+ size int,183+ profile relationProfile,184+ remove cellPos,185+ rng *rand.Rand,186+) bool {187+ choices := scoredRelationChoices(complete, provided, rels, size, profile, remove, false)188+ if len(choices) == 0 {189+ return false190+ }191+ shuffleTopTies(choices, rng)192+193+ for _, choice := range choices {194+ applyRelationCandidate(rels, choice.candidate, complete)195+ saved := puzzle[remove.y][remove.x]196+ puzzle[remove.y][remove.x] = emptyCell197+ if countSolutions(puzzle, size, 2, rels) == 1 {198+ provided[remove.y][remove.x] = false199+ return true200+ }201+ puzzle[remove.y][remove.x] = saved202+ clearRelationCandidate(rels, choice.candidate)203+ }204+205+ return false206+}207+208+func tryPostPassRelationRemoval(209+ puzzle, complete grid,210+ provided [][]bool,211+ rels relations,212+ size int,213+ profile relationProfile,214+ rng *rand.Rand,215+) bool {216+ cells := providedCells(provided)217+ rng.Shuffle(len(cells), func(i, j int) {218+ cells[i], cells[j] = cells[j], cells[i]219+ })220+221+ for _, cell := range cells {222+ if tryRelationRemoval(puzzle, complete, provided, rels, size, profile, cell, rng) {223+ return true224+ }225+ }226+ return false227+}228+229+func tryAdditiveRelation(230+ complete grid,231+ provided [][]bool,232+ rels relations,233+ size int,234+ profile relationProfile,235+ rng *rand.Rand,236+) bool {237+ choices := additiveRelationChoices(complete, provided, rels, size, profile)238+ if len(choices) == 0 {239+ return false240+ }241+ shuffleTopTies(choices, rng)242+ applyRelationCandidate(rels, choices[0].candidate, complete)243+ return true244+}245+246+func scoredRelationChoices(247+ complete grid,248+ provided [][]bool,249+ rels relations,250+ size int,251+ profile relationProfile,252+ remove cellPos,253+ additive bool,254+) []relationChoice {255+ candidates := adjacentRelationCandidates(rels, remove.x, remove.y, size)256+ if len(candidates) == 0 {257+ return nil258+ }259+260+ metrics := analyzeRelations(rels, provided, size)261+ choices := make([]relationChoice, 0, len(candidates))262+ for _, candidate := range candidates {263+ ax, ay, bx, by := relationEndpoints(candidate)264+ otherProvided := false265+ if ax == remove.x && ay == remove.y {266+ otherProvided = provided[by][bx]267+ } else {268+ otherProvided = provided[ay][ax]269+ }270+271+ endpoint := endpointZeroProvided272+ if otherProvided {273+ endpoint = endpointOneProvided274+ }275+276+ choice := relationChoice{277+ candidate: candidate,278+ remove: remove,279+ additive: additive,280+ endpointClass: endpoint,281+ symbol: relationSymbol(complete, candidate),282+ }283+ choice.score = relationChoiceScore(choice, profile, metrics, size)284+ choices = append(choices, choice)285+ }286+287+ slices.SortFunc(choices, func(a, b relationChoice) int {288+ return cmp.Compare(b.score, a.score)289+ })290+ return choices291+}292+293+func additiveRelationChoices(294+ complete grid,295+ provided [][]bool,296+ rels relations,297+ size int,298+ profile relationProfile,299+) []relationChoice {300+ metrics := analyzeRelations(rels, provided, size)301+ choices := make([]relationChoice, 0, 2*size*(size-1))302+ for _, candidate := range allUnusedRelationCandidates(rels, size) {303+ ax, ay, bx, by := relationEndpoints(candidate)304+ endpoint := endpointClass(boolInt(provided[ay][ax]) + boolInt(provided[by][bx]))305+ choice := relationChoice{306+ candidate: candidate,307+ additive: true,308+ endpointClass: endpoint,309+ symbol: relationSymbol(complete, candidate),310+ }311+ choice.score = relationChoiceScore(choice, profile, metrics, size)312+ if endpoint == endpointTwoProvided {313+ choice.score += 5314+ }315+ choices = append(choices, choice)316+ }317+318+ slices.SortFunc(choices, func(a, b relationChoice) int {319+ return cmp.Compare(b.score, a.score)320+ })321+ return choices322+}323+324+func relationChoiceScore(choice relationChoice, profile relationProfile, metrics relationMetrics, size int) int {325+ score := 0326+ score += profile.EndpointWeights[choice.endpointClass] * 10327+328+ band := profile.EndpointBands[choice.endpointClass]329+ currentRatio := ratioForCount(metrics.EndpointCounts[choice.endpointClass], metrics.Total)330+ targetMid := (band.Min + band.Max) / 2331+ if currentRatio < band.Min {332+ score += 18333+ } else {334+ score -= int(math.Round(math.Abs(currentRatio-targetMid) * 20))335+ }336+337+ if metrics.Total > 0 {338+ if choice.symbol == relationSame && metrics.SameCount < metrics.DifferentCount {339+ score += 6340+ }341+ if choice.symbol == relationDiff && metrics.DifferentCount < metrics.SameCount {342+ score += 6343+ }344+ if choice.candidate.horizontal && metrics.HorizontalCount < metrics.VerticalCount {345+ score += 4346+ }347+ if !choice.candidate.horizontal && metrics.VerticalCount < metrics.HorizontalCount {348+ score += 4349+ }350+ }351+352+ pos := relationPosition(choice.candidate)353+ if profile.MinSpacing > 0 {354+ minGap := minDistance(metrics.Positions, pos)355+ if minGap >= profile.MinSpacing {356+ score += minGap357+ } else {358+ score -= 12 * (profile.MinSpacing - minGap + 1)359+ }360+ }361+362+ if profile.MinRegions > 0 {363+ bit := relationRegionBit(size, pos)364+ if metrics.RegionMask&bit == 0 {365+ score += 8366+ }367+ }368+369+ rowSpreadBonus := 0370+ if choice.candidate.horizontal {371+ rowSpreadBonus = 2372+ }373+ score += rowSpreadBonus374+375+ return score376+}377+378+func additiveRelationsNeeded(profile relationProfile, size int, rels relations, provided [][]bool) bool {379+ metrics := analyzeRelations(rels, provided, size)380+ if metrics.Total < profile.MinRelations {381+ return true382+ }383+ if metrics.Total == 0 {384+ return false385+ }386+ twoBand := profile.EndpointBands[endpointTwoProvided]387+ if !ratioWithinBand(metrics.EndpointCounts[endpointTwoProvided], metrics.Total, twoBand) &&388+ ratioForCount(metrics.EndpointCounts[endpointTwoProvided], metrics.Total) < twoBand.Min {389+ return true390+ }391+ return false392+}393+394+func adjacentRelationCandidates(rels relations, x, y, size int) []relationCandidate {395+ candidates := make([]relationCandidate, 0, 4)396+ if x > 0 && rels.horizontal[y][x-1] == relationNone {397+ candidates = append(candidates, relationCandidate{horizontal: true, x: x - 1, y: y})398+ }399+ if x < size-1 && rels.horizontal[y][x] == relationNone {400+ candidates = append(candidates, relationCandidate{horizontal: true, x: x, y: y})401+ }402+ if y > 0 && rels.vertical[y-1][x] == relationNone {403+ candidates = append(candidates, relationCandidate{horizontal: false, x: x, y: y - 1})404+ }405+ if y < size-1 && rels.vertical[y][x] == relationNone {406+ candidates = append(candidates, relationCandidate{horizontal: false, x: x, y: y})407+ }408+ return candidates409+}410+411+func allUnusedRelationCandidates(rels relations, size int) []relationCandidate {412+ candidates := make([]relationCandidate, 0, 2*size*(size-1))413+ for y := 0; y < size; y++ {414+ for x := 0; x < size-1; x++ {415+ if rels.horizontal[y][x] == relationNone {416+ candidates = append(candidates, relationCandidate{horizontal: true, x: x, y: y})417+ }418+ }419+ }420+ for y := 0; y < size-1; y++ {421+ for x := 0; x < size; x++ {422+ if rels.vertical[y][x] == relationNone {423+ candidates = append(candidates, relationCandidate{horizontal: false, x: x, y: y})424+ }425+ }426+ }427+ return candidates428+}429+430+func applyRelationCandidate(rels relations, candidate relationCandidate, complete grid) {431+ if candidate.horizontal {432+ rels.horizontal[candidate.y][candidate.x] = relationForValues(complete[candidate.y][candidate.x], complete[candidate.y][candidate.x+1])433+ return434+ }435+ rels.vertical[candidate.y][candidate.x] = relationForValues(complete[candidate.y][candidate.x], complete[candidate.y+1][candidate.x])436+}437+438+func clearRelationCandidate(rels relations, candidate relationCandidate) {439+ if candidate.horizontal {440+ rels.horizontal[candidate.y][candidate.x] = relationNone441+ return442+ }443+ rels.vertical[candidate.y][candidate.x] = relationNone444+}445+446+func relationEndpoints(candidate relationCandidate) (ax, ay, bx, by int) {447+ if candidate.horizontal {448+ return candidate.x, candidate.y, candidate.x + 1, candidate.y449+ }450+ return candidate.x, candidate.y, candidate.x, candidate.y + 1451+}452+453+func relationPosition(candidate relationCandidate) image.Point {454+ if candidate.horizontal {455+ return horizontalRelationPosition(candidate.x, candidate.y)456+ }457+ return verticalRelationPosition(candidate.x, candidate.y)458+}459+460+func relationSymbol(complete grid, candidate relationCandidate) rune {461+ ax, ay, bx, by := relationEndpoints(candidate)462+ return relationForValues(complete[ay][ax], complete[by][bx])463+}464+465+func providedCells(provided [][]bool) []cellPos {466+ size := len(provided)467+ cells := make([]cellPos, 0, size*size)468+ for y := range size {469+ for x := range size {470+ if provided[y][x] {471+ cells = append(cells, cellPos{x: x, y: y})472+ }473+ }474+ }475+ return cells476+}477+478+func cloneProvided(provided [][]bool) [][]bool {479+ clone := make([][]bool, len(provided))480+ for y := range provided {481+ clone[y] = append([]bool(nil), provided[y]...)482+ }483+ return clone484+}485+486+func minDistance(existing []image.Point, pos image.Point) int {487+ if len(existing) == 0 {488+ return profileDistanceUnset()489+ }490+ best := math.MaxInt491+ for _, prior := range existing {492+ dist := abs(pos.X-prior.X) + abs(pos.Y-prior.Y)493+ if dist < best {494+ best = dist495+ }496+ }497+ return best498+}499+500+func ratioForCount(count, total int) float64 {501+ if total == 0 {502+ return 0503+ }504+ return float64(count) / float64(total)505+}506+507+func shuffleTopTies(choices []relationChoice, rng *rand.Rand) {508+ if len(choices) < 2 {509+ return510+ }511+ topScore := choices[0].score512+ limit := 1513+ for limit < len(choices) && choices[limit].score == topScore {514+ limit++515+ }516+ rng.Shuffle(limit, func(i, j int) {517+ choices[i], choices[j] = choices[j], choices[i]518+ })519+}@@ -0,0 +1,134 @@
1+package takuzuplus2+3+import (4+ "strings"5+6+ "github.com/FelineStateMachine/puzzletea/takuzu"7+)8+9+const (10+ emptyCell = takuzu.EmptyCell11+ zeroCell = takuzu.ZeroCell12+ oneCell = takuzu.OneCell13+14+ relationNone rune = '.'15+ relationSame rune = '='16+ relationDiff rune = 'x'17+)18+19+type grid [][]rune20+21+type relations struct {22+ horizontal [][]rune23+ vertical [][]rune24+}25+26+func newGridFromState(s string) grid {27+ rows := strings.Split(string(s), "\n")28+ g := make(grid, len(rows))29+ for i, row := range rows {30+ g[i] = []rune(row)31+ }32+ return g33+}34+35+func (g grid) String() string {36+ rows := make([]string, len(g))37+ for i, row := range g {38+ rows[i] = string(row)39+ }40+ return strings.Join(rows, "\n")41+}42+43+func (g grid) clone() grid {44+ clone := make(grid, len(g))45+ for y, row := range g {46+ clone[y] = make([]rune, len(row))47+ copy(clone[y], row)48+ }49+ return clone50+}51+52+func newRelations(size int) relations {53+ r := relations{54+ horizontal: make([][]rune, size),55+ vertical: make([][]rune, max(size-1, 0)),56+ }57+ for y := 0; y < size; y++ {58+ r.horizontal[y] = make([]rune, max(size-1, 0))59+ for x := range r.horizontal[y] {60+ r.horizontal[y][x] = relationNone61+ }62+ }63+ for y := 0; y < size-1; y++ {64+ r.vertical[y] = make([]rune, size)65+ for x := range r.vertical[y] {66+ r.vertical[y][x] = relationNone67+ }68+ }69+ return r70+}71+72+func (r relations) clone() relations {73+ clone := newRelations(len(r.horizontal))74+ for y := range r.horizontal {75+ copy(clone.horizontal[y], r.horizontal[y])76+ }77+ for y := range r.vertical {78+ copy(clone.vertical[y], r.vertical[y])79+ }80+ return clone81+}82+83+func serializeRuneRows(rows [][]rune) string {84+ var b strings.Builder85+ for y, row := range rows {86+ b.WriteString(string(row))87+ if y < len(rows)-1 {88+ b.WriteByte('\n')89+ }90+ }91+ return b.String()92+}93+94+func deserializeRuneRows(s string, rows, cols int) [][]rune {95+ if rows <= 0 {96+ return [][]rune{}97+ }98+99+ parsed := strings.Split(s, "\n")100+ out := make([][]rune, rows)101+ for y := 0; y < rows; y++ {102+ out[y] = make([]rune, cols)103+ for x := 0; x < cols; x++ {104+ out[y][x] = relationNone105+ }106+ if y >= len(parsed) {107+ continue108+ }109+ runes := []rune(parsed[y])110+ for x := 0; x < cols && x < len(runes); x++ {111+ out[y][x] = runes[x]112+ }113+ }114+ return out115+}116+117+func countRelations(r relations) int {118+ total := 0119+ for _, row := range r.horizontal {120+ for _, value := range row {121+ if value != relationNone {122+ total++123+ }124+ }125+ }126+ for _, row := range r.vertical {127+ for _, value := range row {128+ if value != relationNone {129+ total++130+ }131+ }132+ }133+ return total134+}@@ -0,0 +1,25 @@
1+# Takuzu+2+3+Fill the grid with two symbols so that the normal Takuzu rules hold, while also obeying fixed relation clues.4+5+## Rules6+7+- **No triples**: Three consecutive identical symbols in a row or column are not allowed.8+- **Equal count**: Each row and column must contain exactly the same number of ● and ○.9+- **Unique lines**: No two rows may be identical, and no two columns may be identical.10+- **Relation clues**: `=` means two adjacent cells are the same. `x` means two adjacent cells are opposite.11+12+Pre-filled cells are locked. Relation clues are also fixed and cannot be edited.13+14+## Controls15+16+| Key | Action |17+|-----|--------|18+| `Arrows` / `wasd` / `hjkl` | Move cursor |19+| `Mouse left-click` | Move to a cell, or cycle the current editable cell |20+| `z` / `0` | Place ● (filled) |21+| `x` / `1` | Place ○ (open) |22+| `Backspace` | Clear cell |23+| `Ctrl+H` | Toggle help bar |24+| `Ctrl+R` | Reset puzzle |25+| `Ctrl+N` | Return to main menu |@@ -0,0 +1,49 @@
1+package takuzuplus2+3+import (4+ "charm.land/bubbles/v2/key"5+ "github.com/FelineStateMachine/puzzletea/game"6+)7+8+type KeyMap struct {9+ game.CursorKeyMap10+ PlaceZero key.Binding11+ PlaceOne key.Binding12+ Clear key.Binding13+}14+15+var DefaultKeyMap = KeyMap{16+ CursorKeyMap: game.DefaultCursorKeyMap,17+ PlaceZero: key.NewBinding(18+ key.WithKeys("z", "0"),19+ key.WithHelp("z/0", "Place ●"),20+ ),21+ PlaceOne: key.NewBinding(22+ key.WithKeys("x", "1"),23+ key.WithHelp("x/1", "Place ○"),24+ ),25+ Clear: key.NewBinding(26+ key.WithKeys("backspace", "delete"),27+ key.WithHelp("bkspc", "Clear"),28+ ),29+}30+31+func (m *Model) updateKeyBindings() {32+ m.keys.Up.SetEnabled(m.cursor.Y > 0)33+ m.keys.Down.SetEnabled(m.cursor.Y < m.size-1)34+ m.keys.Left.SetEnabled(m.cursor.X > 0)35+ m.keys.Right.SetEnabled(m.cursor.X < m.size-1)36+37+ onProvided := m.provided[m.cursor.Y][m.cursor.X]38+ solved := m.solved39+ m.keys.PlaceZero.SetEnabled(!onProvided && !solved)40+ m.keys.PlaceOne.SetEnabled(!onProvided && !solved)41+ m.keys.Clear.SetEnabled(!onProvided && !solved)42+}43+44+func (m Model) GetFullHelp() [][]key.Binding {45+ return [][]key.Binding{46+ {m.keys.Up, m.keys.Down, m.keys.Left, m.keys.Right},47+ {m.keys.PlaceZero, m.keys.PlaceOne, m.keys.Clear},48+ }49+}@@ -0,0 +1,137 @@
1+package takuzuplus2+3+import "github.com/FelineStateMachine/puzzletea/takuzu"4+5+type mrvChoice struct {6+ x, y int7+ vals [2]rune8+ count int9+}10+11+func relationForValues(a, b rune) rune {12+ if a == b {13+ return relationSame14+ }15+ return relationDiff16+}17+18+func relationSatisfied(kind, a, b rune) bool {19+ switch kind {20+ case relationSame:21+ return a == b22+ case relationDiff:23+ return a != b24+ default:25+ return true26+ }27+}28+29+func relationAllowed(g grid, rels relations, x, y int, val rune) bool {30+ if x > 0 {31+ if rel := rels.horizontal[y][x-1]; rel != relationNone && g[y][x-1] != emptyCell && !relationSatisfied(rel, g[y][x-1], val) {32+ return false33+ }34+ }35+ if x < len(g[y])-1 {36+ if rel := rels.horizontal[y][x]; rel != relationNone && g[y][x+1] != emptyCell && !relationSatisfied(rel, val, g[y][x+1]) {37+ return false38+ }39+ }40+ if y > 0 {41+ if rel := rels.vertical[y-1][x]; rel != relationNone && g[y-1][x] != emptyCell && !relationSatisfied(rel, g[y-1][x], val) {42+ return false43+ }44+ }45+ if y < len(g)-1 {46+ if rel := rels.vertical[y][x]; rel != relationNone && g[y+1][x] != emptyCell && !relationSatisfied(rel, val, g[y+1][x]) {47+ return false48+ }49+ }50+ return true51+}52+53+func checkRelations(g grid, rels relations) bool {54+ for y, row := range rels.horizontal {55+ for x, rel := range row {56+ if rel == relationNone || g[y][x] == emptyCell || g[y][x+1] == emptyCell {57+ continue58+ }59+ if !relationSatisfied(rel, g[y][x], g[y][x+1]) {60+ return false61+ }62+ }63+ }64+ for y, row := range rels.vertical {65+ for x, rel := range row {66+ if rel == relationNone || g[y][x] == emptyCell || g[y+1][x] == emptyCell {67+ continue68+ }69+ if !relationSatisfied(rel, g[y][x], g[y+1][x]) {70+ return false71+ }72+ }73+ }74+ return true75+}76+77+func canPlaceWithRelations(g grid, size, x, y int, val rune, rels relations) bool {78+ return takuzu.CanPlaceInGrid(g, size, x, y, val) && relationAllowed(g, rels, x, y, val)79+}80+81+func selectMRVCell(g grid, size int, rels relations) mrvChoice {82+ choice := mrvChoice{x: -1, y: -1, count: 3}83+ for y := range size {84+ for x := range size {85+ if g[y][x] != emptyCell {86+ continue87+ }88+89+ var vals [2]rune90+ count := 091+ if canPlaceWithRelations(g, size, x, y, zeroCell, rels) {92+ vals[count] = zeroCell93+ count++94+ }95+ if canPlaceWithRelations(g, size, x, y, oneCell, rels) {96+ vals[count] = oneCell97+ count++98+ }99+100+ if count == 0 {101+ return mrvChoice{x: x, y: y, count: 0}102+ }103+ if count < choice.count {104+ choice = mrvChoice{x: x, y: y, vals: vals, count: count}105+ }106+ }107+ }108+ if choice.x < 0 {109+ return mrvChoice{x: -1, y: -1, count: 0}110+ }111+ return choice112+}113+114+func countSolutions(g grid, size, limit int, rels relations) int {115+ choice := selectMRVCell(g, size, rels)116+ if choice.x < 0 {117+ if takuzu.CheckConstraintsGrid(g, size) && takuzu.HasUniqueLinesGrid(g, size) && checkRelations(g, rels) {118+ return 1119+ }120+ return 0121+ }122+ if choice.count == 0 {123+ return 0124+ }125+126+ total := 0127+ for i := range choice.count {128+ val := choice.vals[i]129+ g[choice.y][choice.x] = val130+ total += countSolutions(g, size, limit-total, rels)131+ g[choice.y][choice.x] = emptyCell132+ if total >= limit {133+ return total134+ }135+ }136+ return total137+}@@ -0,0 +1,35 @@
1+package takuzuplus2+3+import "github.com/FelineStateMachine/puzzletea/game"4+5+func (m *Model) screenToGrid(screenX, screenY int) (col, row int, ok bool) {6+ ox, oy := m.cachedGridOrigin()7+ return game.DynamicGridScreenToCell(8+ game.DynamicGridMetrics{9+ Width: m.size,10+ Height: m.size,11+ CellWidth: cellWidth,12+ },13+ ox,14+ oy,15+ screenX,16+ screenY,17+ false,18+ )19+}20+21+func (m *Model) cachedGridOrigin() (x, y int) {22+ if m.originValid {23+ return m.originX, m.originY24+ }25+ x, y = m.gridOrigin()26+ m.originX, m.originY = x, y27+ m.originValid = true28+ return x, y29+}30+31+func (m *Model) gridOrigin() (x, y int) {32+ title := game.TitleBarView("Takuzu+", m.modeTitle, m.solved)33+ grid := gridView(*m)34+ return game.DynamicGridOrigin(m.termWidth, m.termHeight, m.View(), title, grid)35+}@@ -0,0 +1,337 @@
1+package takuzuplus2+3+import (4+ "image"5+ "math"6+ "math/rand/v2"7+)8+9+const (10+ endpointZeroProvided = iota11+ endpointOneProvided12+ endpointTwoProvided13+ endpointClassCount14+)15+16+type ratioBand struct {17+ Min float6418+ Max float6419+}20+21+type relationProfile struct {22+ MinRelations int23+ MaxRelations int24+25+ EndpointBands [endpointClassCount]ratioBand26+ EndpointWeights [endpointClassCount]int27+28+ MinSpacing int29+ MinRegions int30+}31+32+type relationMetrics struct {33+ Total int34+ EndpointCounts [endpointClassCount]int35+ SameCount int36+ DifferentCount int37+ HorizontalCount int38+ VerticalCount int39+ RegionMask uint840+ OccupiedRegions int41+ Positions []image.Point42+ MinExistingGap int43+ HasAnyRelations bool44+}45+46+func (p relationProfile) targetCount(rng *rand.Rand) int {47+ if p.MaxRelations <= p.MinRelations {48+ return p.MinRelations49+ }50+ return p.MinRelations + rng.IntN(p.MaxRelations-p.MinRelations+1)51+}52+53+func relationProfiles() []relationProfile {54+ return []relationProfile{55+ {56+ MinRelations: 2,57+ MaxRelations: 4,58+ EndpointBands: [endpointClassCount]ratioBand{59+ {Min: 0.00, Max: 0.05},60+ {Min: 0.70, Max: 0.85},61+ {Min: 0.15, Max: 0.30},62+ },63+ EndpointWeights: [endpointClassCount]int{0, 10, 5},64+ MinSpacing: 2,65+ },66+ {67+ MinRelations: 3,68+ MaxRelations: 5,69+ EndpointBands: [endpointClassCount]ratioBand{70+ {Min: 0.00, Max: 0.05},71+ {Min: 0.65, Max: 0.80},72+ {Min: 0.20, Max: 0.35},73+ },74+ EndpointWeights: [endpointClassCount]int{0, 9, 5},75+ MinSpacing: 2,76+ },77+ {78+ MinRelations: 4,79+ MaxRelations: 6,80+ EndpointBands: [endpointClassCount]ratioBand{81+ {Min: 0.05, Max: 0.15},82+ {Min: 0.60, Max: 0.75},83+ {Min: 0.15, Max: 0.30},84+ },85+ EndpointWeights: [endpointClassCount]int{3, 9, 4},86+ MinRegions: 3,87+ },88+ {89+ MinRelations: 6,90+ MaxRelations: 8,91+ EndpointBands: [endpointClassCount]ratioBand{92+ {Min: 0.10, Max: 0.15},93+ {Min: 0.60, Max: 0.70},94+ {Min: 0.15, Max: 0.25},95+ },96+ EndpointWeights: [endpointClassCount]int{4, 8, 3},97+ MinRegions: 3,98+ },99+ {100+ MinRelations: 8,101+ MaxRelations: 11,102+ EndpointBands: [endpointClassCount]ratioBand{103+ {Min: 0.10, Max: 0.20},104+ {Min: 0.60, Max: 0.70},105+ {Min: 0.10, Max: 0.25},106+ },107+ EndpointWeights: [endpointClassCount]int{5, 8, 2},108+ MinRegions: 3,109+ },110+ {111+ MinRelations: 10,112+ MaxRelations: 14,113+ EndpointBands: [endpointClassCount]ratioBand{114+ {Min: 0.15, Max: 0.20},115+ {Min: 0.55, Max: 0.65},116+ {Min: 0.15, Max: 0.30},117+ },118+ EndpointWeights: [endpointClassCount]int{5, 7, 3},119+ MinRegions: 3,120+ },121+ {122+ MinRelations: 13,123+ MaxRelations: 18,124+ EndpointBands: [endpointClassCount]ratioBand{125+ {Min: 0.15, Max: 0.20},126+ {Min: 0.55, Max: 0.65},127+ {Min: 0.15, Max: 0.30},128+ },129+ EndpointWeights: [endpointClassCount]int{5, 7, 3},130+ MinRegions: 3,131+ },132+ }133+}134+135+func relationPenalty(profile relationProfile, size int, rels relations, provided [][]bool) int {136+ metrics := analyzeRelations(rels, provided, size)137+ penalty := 0138+139+ switch {140+ case metrics.Total < profile.MinRelations:141+ penalty += 1000 * (profile.MinRelations - metrics.Total)142+ case metrics.Total > profile.MaxRelations:143+ penalty += 1000 * (metrics.Total - profile.MaxRelations)144+ }145+146+ if metrics.Total == 0 {147+ return penalty148+ }149+150+ for class, band := range profile.EndpointBands {151+ if !ratioWithinBand(metrics.EndpointCounts[class], metrics.Total, band) {152+ penalty += bandPenalty(metrics.EndpointCounts[class], metrics.Total, band)153+ }154+ }155+156+ if metrics.Total >= 5 {157+ balanceBand := ratioBand{Min: 0.40, Max: 0.60}158+ if !ratioWithinBand(metrics.SameCount, metrics.Total, balanceBand) {159+ penalty += bandPenalty(metrics.SameCount, metrics.Total, balanceBand)160+ }161+ if !ratioWithinBand(metrics.HorizontalCount, metrics.Total, balanceBand) {162+ penalty += bandPenalty(metrics.HorizontalCount, metrics.Total, balanceBand)163+ }164+ }165+166+ if profile.MinSpacing > 0 && metrics.Total > 1 && metrics.MinExistingGap < profile.MinSpacing {167+ penalty += 250 * (profile.MinSpacing - metrics.MinExistingGap)168+ }169+170+ if profile.MinRegions > 0 && metrics.OccupiedRegions < profile.MinRegions {171+ penalty += 400 * (profile.MinRegions - metrics.OccupiedRegions)172+ }173+174+ return penalty175+}176+177+func analyzeRelations(rels relations, provided [][]bool, size int) relationMetrics {178+ metrics := relationMetrics{179+ MinExistingGap: math.MaxInt,180+ }181+182+ appendPosition := func(pos image.Point) {183+ for _, prior := range metrics.Positions {184+ dist := abs(pos.X-prior.X) + abs(pos.Y-prior.Y)185+ if dist < metrics.MinExistingGap {186+ metrics.MinExistingGap = dist187+ }188+ }189+ metrics.Positions = append(metrics.Positions, pos)190+ metrics.RegionMask |= relationRegionBit(size, pos)191+ }192+193+ for y, row := range rels.horizontal {194+ for x, rel := range row {195+ if rel == relationNone {196+ continue197+ }198+ metrics.Total++199+ metrics.HasAnyRelations = true200+ metrics.HorizontalCount++201+ if rel == relationSame {202+ metrics.SameCount++203+ } else {204+ metrics.DifferentCount++205+ }206+ class := endpointClass(boolInt(provided[y][x]) + boolInt(provided[y][x+1]))207+ metrics.EndpointCounts[class]++208+ appendPosition(horizontalRelationPosition(x, y))209+ }210+ }211+212+ for y, row := range rels.vertical {213+ for x, rel := range row {214+ if rel == relationNone {215+ continue216+ }217+ metrics.Total++218+ metrics.HasAnyRelations = true219+ metrics.VerticalCount++220+ if rel == relationSame {221+ metrics.SameCount++222+ } else {223+ metrics.DifferentCount++224+ }225+ class := endpointClass(boolInt(provided[y][x]) + boolInt(provided[y+1][x]))226+ metrics.EndpointCounts[class]++227+ appendPosition(verticalRelationPosition(x, y))228+ }229+ }230+231+ if metrics.MinExistingGap == math.MaxInt {232+ metrics.MinExistingGap = profileDistanceUnset()233+ }234+ metrics.OccupiedRegions = bitsSet(metrics.RegionMask)235+236+ return metrics237+}238+239+func ratioWithinBand(count, total int, band ratioBand) bool {240+ if total == 0 {241+ return band.Min <= 0242+ }243+ actual := float64(count) / float64(total)244+ slack := 1.0 / float64(total)245+ return actual >= maxFloat(0, band.Min-slack) && actual <= minFloat(1, band.Max+slack)246+}247+248+func bandPenalty(count, total int, band ratioBand) int {249+ if total == 0 {250+ return 0251+ }252+ actual := float64(count) / float64(total)253+ switch {254+ case actual < band.Min:255+ return int(math.Ceil((band.Min - actual) * 1000))256+ case actual > band.Max:257+ return int(math.Ceil((actual - band.Max) * 1000))258+ default:259+ return 0260+ }261+}262+263+func relationRegionBit(size int, pos image.Point) uint8 {264+ if size <= 0 {265+ return 0266+ }267+ cut := size268+ regionX := 0269+ regionY := 0270+ if pos.X >= cut {271+ regionX = 1272+ }273+ if pos.Y >= cut {274+ regionY = 1275+ }276+ return 1 << uint(regionY*2+regionX)277+}278+279+func horizontalRelationPosition(x, y int) image.Point {280+ return image.Pt(2*x+1, 2*y)281+}282+283+func verticalRelationPosition(x, y int) image.Point {284+ return image.Pt(2*x, 2*y+1)285+}286+287+func endpointClass(providedCount int) int {288+ switch providedCount {289+ case 0:290+ return endpointZeroProvided291+ case 1:292+ return endpointOneProvided293+ default:294+ return endpointTwoProvided295+ }296+}297+298+func bitsSet(mask uint8) int {299+ count := 0300+ for mask > 0 {301+ count += int(mask & 1)302+ mask >>= 1303+ }304+ return count305+}306+307+func profileDistanceUnset() int {308+ return math.MaxInt / 2309+}310+311+func minFloat(a, b float64) float64 {312+ if a < b {313+ return a314+ }315+ return b316+}317+318+func maxFloat(a, b float64) float64 {319+ if a > b {320+ return a321+ }322+ return b323+}324+325+func boolInt(v bool) int {326+ if v {327+ return 1328+ }329+ return 0330+}331+332+func abs(v int) int {333+ if v < 0 {334+ return -v335+ }336+ return v337+}@@ -0,0 +1,174 @@
1+package takuzuplus2+3+import (4+ "image/color"5+6+ "charm.land/lipgloss/v2"7+ "github.com/FelineStateMachine/puzzletea/game"8+ "github.com/FelineStateMachine/puzzletea/theme"9+)10+11+const cellWidth = game.DynamicGridCellWidth12+13+var renderRuneMap = map[rune]string{14+ zeroCell: " ● ",15+ oneCell: " ○ ",16+ emptyCell: " · ",17+}18+19+func zeroStyle() lipgloss.Style {20+ p := theme.Current()21+ return lipgloss.NewStyle().Foreground(p.Accent).Background(p.BG)22+}23+24+func oneStyle() lipgloss.Style {25+ p := theme.Current()26+ return lipgloss.NewStyle().Foreground(p.Secondary).Background(p.BG)27+}28+29+func emptyStyle() lipgloss.Style {30+ p := theme.Current()31+ return lipgloss.NewStyle().Foreground(p.TextDim).Background(p.BG)32+}33+34+func renderStyleMap() map[rune]lipgloss.Style {35+ return map[rune]lipgloss.Style{36+ zeroCell: zeroStyle(),37+ oneCell: oneStyle(),38+ emptyCell: emptyStyle(),39+ }40+}41+42+func cellView(val rune, isProvided, isCursor, inCursorRow, inCursorCol, solved bool) string {43+ p := theme.Current()44+ styles := renderStyleMap()45+ style, ok := styles[val]46+ if !ok {47+ style = emptyStyle()48+ }49+50+ if isProvided && val != emptyCell {51+ style = style.Bold(true)52+ }53+54+ text, ok := renderRuneMap[val]55+ if !ok {56+ text = renderRuneMap[emptyCell]57+ }58+59+ switch {60+ case isCursor && solved:61+ style = game.CursorSolvedStyle()62+ text = game.CursorLeft + string([]rune(text)[1]) + game.CursorRight63+ case isCursor:64+ style = game.CursorStyle()65+ text = game.CursorLeft + string([]rune(text)[1]) + game.CursorRight66+ case solved:67+ style = style.Foreground(p.SolvedFG).Background(p.SuccessBG)68+ case inCursorRow || inCursorCol:69+ style = style.Background(p.Surface)70+ }71+72+ return style.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(text)73+}74+75+func gridView(m Model) string {76+ return game.RenderDynamicGrid(game.DynamicGridSpec{77+ Width: m.size,78+ Height: m.size,79+ Solved: m.solved,80+ Cell: func(x, y int) string {81+ return cellView(82+ m.grid[y][x],83+ m.provided[y][x],84+ x == m.cursor.X && y == m.cursor.Y,85+ y == m.cursor.Y,86+ x == m.cursor.X,87+ m.solved,88+ )89+ },90+ ZoneAt: func(_, _ int) int {91+ return 092+ },93+ BridgeFill: func(bridge game.DynamicGridBridge) color.Color {94+ if m.solved {95+ return theme.Current().SuccessBG96+ }97+ if bg := relationBridgeBackground(m, bridge); bg != nil {98+ return bg99+ }100+ for i := 0; i < bridge.Count; i++ {101+ cell := bridge.Cells[i]102+ if cell.X == m.cursor.X || cell.Y == m.cursor.Y {103+ return theme.Current().Surface104+ }105+ }106+ return nil107+ },108+ VerticalBridgeText: func(x, y int) string {109+ if x <= 0 || x >= m.size {110+ return ""111+ }112+ rel := m.relations.horizontal[y][x-1]113+ if rel == relationNone {114+ return ""115+ }116+ return string(rel)117+ },118+ HorizontalBridgeText: func(x, y int) string {119+ if y <= 0 || y >= m.size {120+ return ""121+ }122+ rel := m.relations.vertical[y-1][x]123+ if rel == relationNone {124+ return ""125+ }126+ return string(rel)127+ },128+ })129+}130+131+func relationBridgeBackground(m Model, bridge game.DynamicGridBridge) color.Color {132+ switch bridge.Kind {133+ case game.DynamicGridBridgeVertical:134+ if bridge.X <= 0 || bridge.X >= m.size || bridge.Y < 0 || bridge.Y >= m.size {135+ return nil136+ }137+ return relationStateBackground(relationState(m.relations.horizontal[bridge.Y][bridge.X-1], m.grid[bridge.Y][bridge.X-1], m.grid[bridge.Y][bridge.X]))138+ case game.DynamicGridBridgeHorizontal:139+ if bridge.Y <= 0 || bridge.Y >= m.size || bridge.X < 0 || bridge.X >= m.size {140+ return nil141+ }142+ return relationStateBackground(relationState(m.relations.vertical[bridge.Y-1][bridge.X], m.grid[bridge.Y-1][bridge.X], m.grid[bridge.Y][bridge.X]))143+ default:144+ return nil145+ }146+}147+148+func relationState(rel, left, right rune) int {149+ if rel == relationNone || left == emptyCell || right == emptyCell {150+ return 0151+ }152+ if relationSatisfied(rel, left, right) {153+ return 1154+ }155+ return -1156+}157+158+func relationStateBackground(state int) color.Color {159+ switch state {160+ case 1:161+ return theme.Current().SuccessBG162+ case -1:163+ return game.ConflictBG()164+ default:165+ return nil166+ }167+}168+169+func statusBarView(showFullHelp bool) string {170+ if showFullHelp {171+ return game.StatusBarStyle().Render("arrows/wasd: move mouse: click/cycle z: ● x: ○ bkspc: clear =: same clue x: opposite clue ctrl+n: menu ctrl+r: reset ctrl+h: help")172+ }173+ return game.StatusBarStyle().Render("mouse: click/cycle z: ● x: ○ fixed clues: = and x")174+}@@ -0,0 +1,534 @@
1+package takuzuplus2+3+import (4+ "encoding/json"5+ "fmt"6+ "image/color"7+ "math/rand/v2"8+ "strings"9+ "testing"10+11+ tea "charm.land/bubbletea/v2"12+ "github.com/FelineStateMachine/puzzletea/game"13+ "github.com/FelineStateMachine/puzzletea/theme"14+ "github.com/charmbracelet/x/ansi"15+)16+17+func makeGrid4(rows ...string) grid {18+ if len(rows) != 4 {19+ panic("makeGrid4 requires exactly 4 rows")20+ }21+ g := make(grid, 4)22+ for i, row := range rows {23+ g[i] = []rune(row)24+ }25+ return g26+}27+28+func validGrid4() grid {29+ return makeGrid4(30+ "0011",31+ "1100",32+ "1010",33+ "0101",34+ )35+}36+37+func TestCanPlaceWithRelations(t *testing.T) {38+ rels := newRelations(4)39+ rels.horizontal[0][0] = relationSame40+ rels.horizontal[0][1] = relationDiff41+42+ g := makeGrid4(43+ "0...",44+ "....",45+ "....",46+ "....",47+ )48+49+ if canPlaceWithRelations(g, 4, 1, 0, oneCell, rels) {50+ t.Fatal("expected same relation to reject opposite value")51+ }52+ if !canPlaceWithRelations(g, 4, 1, 0, zeroCell, rels) {53+ t.Fatal("expected same relation to allow equal value")54+ }55+56+ g[0][1] = zeroCell57+ if canPlaceWithRelations(g, 4, 2, 0, zeroCell, rels) {58+ t.Fatal("expected opposite relation to reject equal value")59+ }60+ if !canPlaceWithRelations(g, 4, 2, 0, oneCell, rels) {61+ t.Fatal("expected opposite relation to allow different value")62+ }63+}64+65+func TestCountSolutionsWithRelations(t *testing.T) {66+ g := makeGrid4(67+ "001.",68+ "1100",69+ "1010",70+ "0101",71+ )72+ rels := newRelations(4)73+ rels.horizontal[0][2] = relationSame74+75+ if got := countSolutions(g.clone(), 4, 2, rels); got != 1 {76+ t.Fatalf("countSolutions = %d, want 1", got)77+ }78+}79+80+func TestGeneratePuzzle(t *testing.T) {81+ rng := rand.New(rand.NewPCG(1, 2))82+ complete := generateCompleteSeeded(6, rng)83+ mode := Modes[1].(TakuzuPlusMode)84+ puzzle, provided, rels := generatePuzzleSeeded(complete, 6, 0.40, mode.profile, rng)85+86+ if got := countSolutions(puzzle.clone(), 6, 2, rels); got != 1 {87+ t.Fatalf("generated puzzle has %d solutions, want 1", got)88+ }89+90+ relationCount := 091+ for y, row := range rels.horizontal {92+ for x, rel := range row {93+ if rel == relationNone {94+ continue95+ }96+ relationCount++97+ if want := relationForValues(complete[y][x], complete[y][x+1]); rel != want {98+ t.Fatalf("horizontal relation (%d,%d) = %q, want %q", x, y, rel, want)99+ }100+ }101+ }102+ for y, row := range rels.vertical {103+ for x, rel := range row {104+ if rel == relationNone {105+ continue106+ }107+ relationCount++108+ if want := relationForValues(complete[y][x], complete[y+1][x]); rel != want {109+ t.Fatalf("vertical relation (%d,%d) = %q, want %q", x, y, rel, want)110+ }111+ }112+ }113+ if relationCount == 0 {114+ t.Fatal("expected seeded Takuzu+ generation to emit at least one relation clue")115+ }116+117+ for y := range puzzle {118+ for x := range puzzle[y] {119+ isEmpty := puzzle[y][x] == emptyCell120+ if isEmpty == provided[y][x] {121+ t.Fatalf("provided mismatch at (%d,%d): empty=%v provided=%v", x, y, isEmpty, provided[y][x])122+ }123+ }124+ }125+}126+127+func TestRelationChoiceScorePrefersMixedOverTwoProvided(t *testing.T) {128+ profile := modeProfiles[0]129+ complete := makeGrid4(130+ "0011",131+ "1100",132+ "1010",133+ "0101",134+ )135+ provided := [][]bool{136+ {true, true, true, true},137+ {true, true, false, true},138+ {true, true, true, true},139+ {true, true, true, true},140+ }141+ rels := newRelations(4)142+143+ choices := additiveRelationChoices(complete, provided, rels, 4, profile)144+ if len(choices) == 0 {145+ t.Fatal("expected additive relation choices")146+ }147+ if choices[0].endpointClass != endpointOneProvided {148+ t.Fatalf("top additive choice endpoint class = %d, want mixed(1)", choices[0].endpointClass)149+ }150+}151+152+func TestRelationChoiceScorePrefersUnderrepresentedSymbolAndOrientation(t *testing.T) {153+ profile := modeProfiles[3]154+ provided := [][]bool{155+ {true, true, true, true},156+ {true, true, true, true},157+ {true, true, true, true},158+ {true, true, true, true},159+ }160+ rels := newRelations(4)161+ rels.horizontal[0][0] = relationSame162+ rels.horizontal[1][0] = relationSame163+ metrics := analyzeRelations(rels, provided, 4)164+165+ diffVertical := relationChoice{166+ candidate: relationCandidate{horizontal: false, x: 2, y: 0},167+ endpointClass: endpointTwoProvided,168+ symbol: relationDiff,169+ }170+ diffVertical.score = relationChoiceScore(diffVertical, profile, metrics, 4)171+172+ sameHorizontal := relationChoice{173+ candidate: relationCandidate{horizontal: true, x: 2, y: 2},174+ endpointClass: endpointTwoProvided,175+ symbol: relationSame,176+ }177+ sameHorizontal.score = relationChoiceScore(sameHorizontal, profile, metrics, 4)178+179+ if diffVertical.score <= sameHorizontal.score {180+ t.Fatalf("expected underrepresented diff/vertical clue score %d to exceed same/horizontal score %d", diffVertical.score, sameHorizontal.score)181+ }182+}183+184+func TestRelationChoiceScoreRespectsSpacingPenalty(t *testing.T) {185+ profile := modeProfiles[0]186+ provided := [][]bool{187+ {true, true, true, true},188+ {true, true, true, true},189+ {true, true, true, true},190+ {true, true, true, true},191+ }192+ rels := newRelations(4)193+ rels.horizontal[0][0] = relationSame194+ metrics := analyzeRelations(rels, provided, 4)195+196+ closeChoice := relationChoice{197+ candidate: relationCandidate{horizontal: true, x: 1, y: 0},198+ endpointClass: endpointTwoProvided,199+ symbol: relationDiff,200+ }201+ closeChoice.score = relationChoiceScore(closeChoice, profile, metrics, 4)202+203+ farChoice := relationChoice{204+ candidate: relationCandidate{horizontal: true, x: 2, y: 3},205+ endpointClass: endpointTwoProvided,206+ symbol: relationDiff,207+ }208+ farChoice.score = relationChoiceScore(farChoice, profile, metrics, 4)209+210+ if farChoice.score <= closeChoice.score {211+ t.Fatalf("expected far clue score %d to exceed clustered clue score %d", farChoice.score, closeChoice.score)212+ }213+}214+215+func TestGeneratePuzzleSeeded_ModeRelationBands(t *testing.T) {216+ for i, item := range Modes {217+ mode := item.(TakuzuPlusMode)218+ t.Run(mode.Title(), func(t *testing.T) {219+ rng := rand.New(rand.NewPCG(uint64(101+i), uint64(701+i)))220+ complete := generateCompleteSeeded(mode.Size, rng)221+ _, _, rels := generatePuzzleSeeded(complete, mode.Size, mode.Prefilled, mode.profile, rng)222+223+ if count := countRelations(rels); count < mode.profile.MinRelations || count > mode.profile.MaxRelations {224+ t.Fatalf("relation count = %d, want %d..%d", count, mode.profile.MinRelations, mode.profile.MaxRelations)225+ }226+ })227+ }228+}229+230+func TestSampledModeDistributionStaysNearProfile(t *testing.T) {231+ if testing.Short() {232+ t.Skip("skipping sampled distribution test in short mode")233+ }234+235+ sampleCounts := []int{4, 4, 4, 3, 2, 1, 0}236+ for i, item := range Modes {237+ mode := item.(TakuzuPlusMode)238+ samples := sampleCounts[i]239+ if samples == 0 {240+ continue241+ }242+243+ t.Run(fmt.Sprintf("%s-%d", mode.Title(), samples), func(t *testing.T) {244+ totalRelations := 0245+ totalHorizontal := 0246+ totalSame := 0247+248+ for sample := 0; sample < samples; sample++ {249+ rng := rand.New(rand.NewPCG(uint64(1000+i*37+sample), uint64(2000+i*41+sample)))250+ complete := generateCompleteSeeded(mode.Size, rng)251+ _, provided, rels := generatePuzzleSeeded(complete, mode.Size, mode.Prefilled, mode.profile, rng)252+ metrics := analyzeRelations(rels, provided, mode.Size)253+ totalRelations += metrics.Total254+ totalHorizontal += metrics.HorizontalCount255+ totalSame += metrics.SameCount256+ }257+258+ avgRelations := float64(totalRelations) / float64(samples)259+ targetMid := float64(mode.profile.MinRelations+mode.profile.MaxRelations) / 2260+ if diff := avgRelations - targetMid; diff < -2.0 || diff > 2.0 {261+ t.Fatalf("average relations %.2f too far from target midpoint %.2f", avgRelations, targetMid)262+ }263+264+ if totalRelations >= 5 {265+ horizontalRatio := float64(totalHorizontal) / float64(totalRelations)266+ sameRatio := float64(totalSame) / float64(totalRelations)267+ if horizontalRatio < 0.30 || horizontalRatio > 0.70 {268+ t.Fatalf("horizontal ratio %.2f too skewed", horizontalRatio)269+ }270+ if sameRatio < 0.30 || sameRatio > 0.70 {271+ t.Fatalf("same-symbol ratio %.2f too skewed", sameRatio)272+ }273+ }274+ })275+ }276+}277+278+func TestSaveLoadRoundTrip(t *testing.T) {279+ rels := newRelations(4)280+ rels.horizontal[0][0] = relationSame281+ rels.vertical[1][2] = relationDiff282+283+ puzzle := makeGrid4(284+ "0.11",285+ "1.0.",286+ ".0.1",287+ "0101",288+ )289+ provided := [][]bool{290+ {true, false, true, true},291+ {true, false, true, false},292+ {false, true, false, true},293+ {true, true, true, true},294+ }295+296+ model := Model{297+ size: 4,298+ grid: puzzle,299+ initialGrid: puzzle.clone(),300+ provided: provided,301+ relations: rels,302+ keys: DefaultKeyMap,303+ modeTitle: "Medium",304+ }305+306+ data, err := model.GetSave()307+ if err != nil {308+ t.Fatal(err)309+ }310+311+ loaded, err := ImportModel(data)312+ if err != nil {313+ t.Fatal(err)314+ }315+ if loaded.relations.horizontal[0][0] != relationSame {316+ t.Fatal("horizontal relation not preserved")317+ }318+ if loaded.relations.vertical[1][2] != relationDiff {319+ t.Fatal("vertical relation not preserved")320+ }321+ if loaded.provided[0][1] {322+ t.Fatal("provided mask should preserve editable cell")323+ }324+}325+326+func TestGridViewRendersRelationClues(t *testing.T) {327+ rels := newRelations(4)328+ rels.horizontal[0][0] = relationDiff329+ rels.vertical[0][1] = relationSame330+331+ m := Model{332+ size: 4,333+ grid: validGrid4(),334+ provided: [][]bool{335+ {true, true, true, true},336+ {true, true, true, true},337+ {true, true, true, true},338+ {true, true, true, true},339+ },340+ relations: rels,341+ keys: DefaultKeyMap,342+ }343+344+ view := ansi.Strip(gridView(m))345+ if !strings.Contains(view, "x") {346+ t.Fatal("expected vertical relation clue in view")347+ }348+ if !strings.Contains(view, "=") {349+ t.Fatal("expected horizontal relation clue in view")350+ }351+}352+353+func TestSaveJSONFields(t *testing.T) {354+ m := Model{355+ size: 4,356+ grid: validGrid4(),357+ initialGrid: validGrid4(),358+ provided: [][]bool{359+ {true, true, true, true},360+ {true, true, true, true},361+ {true, true, true, true},362+ {true, true, true, true},363+ },364+ relations: newRelations(4),365+ keys: DefaultKeyMap,366+ }367+368+ data, err := m.GetSave()369+ if err != nil {370+ t.Fatal(err)371+ }372+373+ var save Save374+ if err := json.Unmarshal(data, &save); err != nil {375+ t.Fatal(err)376+ }377+ if save.HorizontalRelations == "" {378+ t.Fatal("expected horizontal_relations to be serialized")379+ }380+ if save.VerticalRelations == "" {381+ t.Fatal("expected vertical_relations to be serialized")382+ }383+}384+385+func TestMouseClickMovesCursor(t *testing.T) {386+ m := testMouseModel()387+388+ x, y := takuzuPlusCellScreenCoords(&m, 2, 1)389+ next, _ := m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})390+ got := next.(Model)391+392+ if got.cursor.X != 2 || got.cursor.Y != 1 {393+ t.Fatalf("cursor = (%d,%d), want (2,1)", got.cursor.X, got.cursor.Y)394+ }395+ if got.grid[1][2] != emptyCell {396+ t.Fatalf("clicked destination cell changed to %q, want unchanged empty", got.grid[1][2])397+ }398+}399+400+func TestMouseClickSameCellCyclesEditableCell(t *testing.T) {401+ m := testMouseModel()402+ m.cursor.X, m.cursor.Y = 1, 1403+404+ x, y := takuzuPlusCellScreenCoords(&m, 1, 1)405+406+ next, _ := m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})407+ got := next.(Model)408+ if got.grid[1][1] != zeroCell {409+ t.Fatalf("first click = %q, want %q", got.grid[1][1], zeroCell)410+ }411+412+ next, _ = got.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})413+ got = next.(Model)414+ if got.grid[1][1] != oneCell {415+ t.Fatalf("second click = %q, want %q", got.grid[1][1], oneCell)416+ }417+418+ next, _ = got.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})419+ got = next.(Model)420+ if got.grid[1][1] != emptyCell {421+ t.Fatalf("third click = %q, want %q", got.grid[1][1], emptyCell)422+ }423+}424+425+func TestMouseClickSameCellDoesNotCycleProvidedCell(t *testing.T) {426+ m := testMouseModel()427+ m.cursor.X, m.cursor.Y = 0, 0428+429+ x, y := takuzuPlusCellScreenCoords(&m, 0, 0)430+ next, _ := m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})431+ got := next.(Model)432+433+ if got.grid[0][0] != zeroCell {434+ t.Fatalf("provided cell changed to %q, want %q", got.grid[0][0], zeroCell)435+ }436+}437+438+func TestRelationBridgeBackgroundSatisfied(t *testing.T) {439+ m := testMouseModel()440+ m.grid[1][1] = zeroCell441+ m.grid[1][2] = zeroCell442+ m.relations.horizontal[1][1] = relationSame443+444+ got := relationBridgeBackground(m, game.DynamicGridBridge{445+ Kind: game.DynamicGridBridgeVertical,446+ X: 2,447+ Y: 1,448+ Count: 2,449+ })450+451+ if !sameColor(got, theme.Current().SuccessBG) {452+ t.Fatal("expected satisfied relation clue to use success background")453+ }454+}455+456+func TestRelationBridgeBackgroundConflict(t *testing.T) {457+ m := testMouseModel()458+ m.grid[0][0] = zeroCell459+ m.grid[1][0] = zeroCell460+ m.relations.vertical[0][0] = relationDiff461+462+ got := relationBridgeBackground(m, game.DynamicGridBridge{463+ Kind: game.DynamicGridBridgeHorizontal,464+ X: 0,465+ Y: 1,466+ Count: 2,467+ })468+469+ if !sameColor(got, game.ConflictBG()) {470+ t.Fatal("expected conflicting relation clue to use conflict background")471+ }472+}473+474+func TestRelationBridgeBackgroundIncompleteIsNeutral(t *testing.T) {475+ m := testMouseModel()476+ m.grid[1][1] = emptyCell477+ m.grid[1][2] = oneCell478+ m.relations.horizontal[1][1] = relationDiff479+480+ got := relationBridgeBackground(m, game.DynamicGridBridge{481+ Kind: game.DynamicGridBridgeVertical,482+ X: 2,483+ Y: 1,484+ Count: 2,485+ })486+487+ if got != nil {488+ t.Fatal("expected incomplete relation clue to stay neutral")489+ }490+}491+492+func testMouseModel() Model {493+ return Model{494+ size: 4,495+ grid: makeGrid4(496+ "0..1",497+ "....",498+ "....",499+ "1..0",500+ ),501+ initialGrid: makeGrid4(502+ "0..1",503+ "....",504+ "....",505+ "1..0",506+ ),507+ provided: [][]bool{508+ {true, false, false, true},509+ {false, false, false, false},510+ {false, false, false, false},511+ {true, false, false, true},512+ },513+ relations: newRelations(4),514+ keys: DefaultKeyMap,515+ modeTitle: "Test",516+ termWidth: 120,517+ termHeight: 40,518+ originValid: false,519+ }520+}521+522+func takuzuPlusCellScreenCoords(m *Model, col, row int) (int, int) {523+ ox, oy := m.gridOrigin()524+ return ox + col*(cellWidth+1), oy + row*2525+}526+527+func sameColor(a, b color.Color) bool {528+ if a == nil || b == nil {529+ return a == b530+ }531+ ar, ag, ab, aa := a.RGBA()532+ br, bg, bb, ba := b.RGBA()533+ return ar == br && ag == bg && ab == bb && aa == ba534+}@@ -84,10 +84,15 @@ func InitList(items []list.Item, title string) list.Model {
8484 // colors sourced from the active theme color set.8585 func InitCategoryList(items []list.Item, title string) list.Model {8686 p := theme.Current()87- d := categoryColorDelegate{DefaultDelegate: newMenuDelegate(p)}87+ base := newMenuDelegate(p)88+ base.ShowDescription = false89+ base.SetHeight(1)90+ base.SetSpacing(0)91+ d := categoryColorDelegate{DefaultDelegate: base}8892 8993 l := list.New(items, d, 64, 64)9094 configureMenuList(&l, title)95+ l.SetFilteringEnabled(true)9196 return l9297 }9398 @@ -97,16 +97,24 @@ func TestInitListDisablesQuitAndFilter(t *testing.T) {
9797 }9898 }9999 100-func TestInitCategoryListDisablesQuitAndFilter(t *testing.T) {101- l := InitCategoryList(nil, "Categories")100+func TestInitCategoryListEnablesFilterAndHidesDescriptions(t *testing.T) {101+ items := []list.Item{102+ MenuItem{ItemTitle: "Sudoku", Desc: "number puzzle"},103+ }104+ l := InitCategoryList(items, "Categories")102105 103- if l.FilteringEnabled() {104- t.Error("expected filtering to be disabled")106+ if !l.FilteringEnabled() {107+ t.Error("expected filtering to be enabled")105108 }106109 107110 if l.ShowHelp() {108111 t.Error("expected help to be disabled")109112 }113+114+ rendered := ansi.Strip(l.View())115+ if strings.Contains(rendered, "number puzzle") {116+ t.Fatalf("expected category list to hide item descriptions\nview:\n%s", rendered)117+ }110118 }111119 112120 func TestThemeListFilterDoesNotSplitTitle(t *testing.T) {