clone

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

layout changes maybe. polish ux in games

Dami committed 12dcb68
commit
12dcb68efc27c71c7a0e260a73ef9a785e49509d
parent
13be83c6cdf207ed0809f879492416edcc0e7cfa
author
Dami <dami.etoile@gmail.com>
49 files changed, +4382 -186
MREADME.md7+-
Mapp/help_layout_test.go28++
Mapp/model.go18+
Aapp/seed_input.go173++++++++++
Mapp/update.go164++++++++-
Mapp/update_test.go112++++++
Mapp/view.go95+++++-
Mcatalog/catalog.go2+
Mfillomino/fillomino_test.go172++++++++++
Mfillomino/style.go130+++++++-
Mgame/dynamic_grid.go128+++++--
Mgame/dynamic_grid_test.go48+++
Mpdfexport/jsonl_test.go43++
Mpdfexport/printdata.go95+++++-
Mpdfexport/printdata_test.go27++
Mpdfexport/types.go8+-
Mresolve/resolve.go95++++-
Mrippleeffect/rippleeffect_test.go128+++++++
Mrippleeffect/style.go112++++++
Msession/session.go16+
Msession/session_test.go14+
Mshikaku/puzzle.go25+-
Mshikaku/shikaku_test.go44++
Mtakuzu/Model.go75+++-
Mtakuzu/PrintAdapter.go67++++-
Mtakuzu/README.md1+
Mtakuzu/generator.go34++
Mtakuzu/grid.go14+
Mtakuzu/help.md1+
Atakuzu/mouse.go35++
Mtakuzu/print_adapter_test.go75++++
Mtakuzu/style.go124+++----
Mtakuzu/takuzu_test.go87+++++
Atakuzuplus/Export.go107++++++
Atakuzuplus/Gamemode.go73++++
Atakuzuplus/Model.go186++++++++++
Atakuzuplus/PrintAdapter.go29++
Atakuzuplus/README.md9+
Atakuzuplus/generator.go519+++++++++++++++++++++++++++++
Atakuzuplus/grid.go134++++++++
Atakuzuplus/help.md25+
Atakuzuplus/keys.go49+++
Atakuzuplus/logic.go137++++++++
Atakuzuplus/mouse.go35++
Atakuzuplus/profile.go337+++++++++++++++++++
Atakuzuplus/style.go174++++++++++
Atakuzuplus/takuzuplus_test.go534++++++++++++++++++++++++++++++
Mui/list.go7+-
Mui/ui_test.go16+-
M README.md +4 -3
@@ -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 ![PuzzleTea menu](vhs/menu.gif)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
M app/help_layout_test.go +28 -0
M app/model.go +18 -0
A app/seed_input.go +173 -0
@@ -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+}
M app/update.go +150 -14
@@ -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:
M app/update_test.go +112 -0
@@ -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"))
M app/view.go +87 -8
@@ -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 {
M catalog/catalog.go +2 -0
M fillomino/fillomino_test.go +172 -0
@@ -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{
M fillomino/style.go +123 -7
@@ -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 {
M game/dynamic_grid.go +91 -37
@@ -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
M game/dynamic_grid_test.go +48 -0
M pdfexport/jsonl_test.go +43 -0
@@ -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")
M pdfexport/printdata.go +92 -3
@@ -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
M pdfexport/printdata_test.go +27 -0
M pdfexport/types.go +5 -3
M resolve/resolve.go +70 -25
@@ -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+}
M rippleeffect/rippleeffect_test.go +128 -0
@@ -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
M rippleeffect/style.go +112 -0
@@ -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 {
M session/session.go +16 -0
M session/session_test.go +14 -0
M shikaku/puzzle.go +24 -1
M shikaku/shikaku_test.go +44 -0
@@ -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{
M takuzu/Model.go +62 -13
@@ -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+}
M takuzu/PrintAdapter.go +65 -2
@@ -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 }
M takuzu/README.md +1 -0
M takuzu/generator.go +34 -0
M takuzu/grid.go +14 -0
M takuzu/help.md +1 -0
A takuzu/mouse.go +35 -0
M takuzu/print_adapter_test.go +75 -0
@@ -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+}
M takuzu/style.go +59 -65
@@ -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 }
M takuzu/takuzu_test.go +87 -0
@@ -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+}
A takuzuplus/Export.go +107 -0
@@ -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+}
A takuzuplus/Gamemode.go +73 -0
@@ -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+}
A takuzuplus/Model.go +186 -0
@@ -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+}
A takuzuplus/PrintAdapter.go +29 -0
A takuzuplus/README.md +9 -0
A takuzuplus/generator.go +519 -0
@@ -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+}
A takuzuplus/grid.go +134 -0
@@ -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+}
A takuzuplus/help.md +25 -0
A takuzuplus/keys.go +49 -0
A takuzuplus/logic.go +137 -0
@@ -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+}
A takuzuplus/mouse.go +35 -0
A takuzuplus/profile.go +337 -0
@@ -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+}
A takuzuplus/style.go +174 -0
@@ -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+}
A takuzuplus/takuzuplus_test.go +534 -0
@@ -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+}
M ui/list.go +6 -1
M ui/ui_test.go +12 -4