- commit
- 35d88ce1dba67c31115ba0cf8f250591bbcc3e78
- parent
- 428ce97e524cfa92d621feb9a9ce8dddce4653bd
- author
- Dami <dami.etoile@gmail.com>
@@ -0,0 +1,196 @@
1+package app2+3+import (4+ "fmt"5+ "image/color"6+7+ "github.com/FelineStateMachine/puzzletea/theme"8+ "github.com/charmbracelet/glamour/ansi"9+)10+11+func helpMarkdownStyle(p theme.Palette) ansi.StyleConfig {12+ return ansi.StyleConfig{13+ Document: ansi.StyleBlock{14+ StylePrimitive: ansi.StylePrimitive{15+ Color: colorPtr(p.FG),16+ },17+ },18+ BlockQuote: ansi.StyleBlock{19+ StylePrimitive: ansi.StylePrimitive{20+ Color: colorPtr(p.TextDim),21+ },22+ Indent: uintPtr(1),23+ IndentToken: stringPtr("│ "),24+ Margin: uintPtr(1),25+ },26+ Paragraph: ansi.StyleBlock{27+ StylePrimitive: ansi.StylePrimitive{28+ Color: colorPtr(p.FG),29+ },30+ Margin: uintPtr(1),31+ },32+ List: ansi.StyleList{33+ StyleBlock: ansi.StyleBlock{34+ StylePrimitive: ansi.StylePrimitive{35+ Color: colorPtr(p.FG),36+ },37+ Margin: uintPtr(1),38+ },39+ LevelIndent: 2,40+ },41+ Heading: ansi.StyleBlock{42+ StylePrimitive: ansi.StylePrimitive{43+ Color: colorPtr(p.Accent),44+ Bold: boolPtr(true),45+ },46+ Margin: uintPtr(1),47+ },48+ H1: ansi.StyleBlock{49+ StylePrimitive: ansi.StylePrimitive{50+ Color: colorPtr(p.Accent),51+ Bold: boolPtr(true),52+ },53+ Margin: uintPtr(1),54+ },55+ H2: ansi.StyleBlock{56+ StylePrimitive: ansi.StylePrimitive{57+ Color: colorPtr(p.AccentSoft),58+ Bold: boolPtr(true),59+ },60+ Margin: uintPtr(1),61+ },62+ H3: ansi.StyleBlock{63+ StylePrimitive: ansi.StylePrimitive{64+ Color: colorPtr(p.Info),65+ Bold: boolPtr(true),66+ },67+ Margin: uintPtr(1),68+ },69+ H4: ansi.StyleBlock{70+ StylePrimitive: ansi.StylePrimitive{71+ Color: colorPtr(p.Secondary),72+ Bold: boolPtr(true),73+ },74+ },75+ H5: ansi.StyleBlock{76+ StylePrimitive: ansi.StylePrimitive{77+ Color: colorPtr(p.Linked),78+ Bold: boolPtr(true),79+ },80+ },81+ H6: ansi.StyleBlock{82+ StylePrimitive: ansi.StylePrimitive{83+ Color: colorPtr(p.TextDim),84+ Bold: boolPtr(true),85+ },86+ },87+ Text: ansi.StylePrimitive{88+ Color: colorPtr(p.FG),89+ },90+ Emph: ansi.StylePrimitive{91+ Color: colorPtr(p.Secondary),92+ Italic: boolPtr(true),93+ },94+ Strong: ansi.StylePrimitive{95+ Color: colorPtr(p.AccentSoft),96+ Bold: boolPtr(true),97+ },98+ HorizontalRule: ansi.StylePrimitive{99+ Color: colorPtr(p.Border),100+ },101+ Item: ansi.StylePrimitive{102+ Color: colorPtr(p.Accent),103+ },104+ Enumeration: ansi.StylePrimitive{105+ Color: colorPtr(p.Accent),106+ },107+ Task: ansi.StyleTask{108+ StylePrimitive: ansi.StylePrimitive{109+ Color: colorPtr(p.Accent),110+ },111+ Ticked: "[x]",112+ Unticked: "[ ]",113+ },114+ Link: ansi.StylePrimitive{115+ Color: colorPtr(p.Highlight),116+ Underline: boolPtr(true),117+ },118+ LinkText: ansi.StylePrimitive{119+ Color: colorPtr(p.Highlight),120+ Underline: boolPtr(true),121+ },122+ Code: ansi.StyleBlock{123+ StylePrimitive: ansi.StylePrimitive{124+ Color: colorPtr(p.AccentSoft),125+ BackgroundColor: colorPtr(p.Surface),126+ },127+ },128+ CodeBlock: ansi.StyleCodeBlock{129+ StyleBlock: ansi.StyleBlock{130+ StylePrimitive: ansi.StylePrimitive{131+ Color: colorPtr(p.FG),132+ BackgroundColor: colorPtr(p.Surface),133+ },134+ Margin: uintPtr(1),135+ },136+ },137+ Table: ansi.StyleTable{138+ StyleBlock: ansi.StyleBlock{139+ StylePrimitive: ansi.StylePrimitive{140+ Color: colorPtr(p.FG),141+ },142+ Margin: uintPtr(1),143+ },144+ CenterSeparator: stringPtr("┼"),145+ ColumnSeparator: stringPtr("│"),146+ RowSeparator: stringPtr("─"),147+ },148+ DefinitionTerm: ansi.StylePrimitive{149+ Color: colorPtr(p.Accent),150+ Bold: boolPtr(true),151+ },152+ DefinitionDescription: ansi.StylePrimitive{153+ Color: colorPtr(p.FG),154+ },155+ Strikethrough: ansi.StylePrimitive{156+ Color: colorPtr(p.TextDim),157+ },158+ }159+}160+161+func helpMarkdownThemeKey(p theme.Palette) string {162+ return fmt.Sprintf(163+ "%s:%s:%s:%s:%s:%s:%s:%s:%s",164+ hexColor(p.BG),165+ hexColor(p.FG),166+ hexColor(p.Surface),167+ hexColor(p.Accent),168+ hexColor(p.AccentSoft),169+ hexColor(p.Info),170+ hexColor(p.Highlight),171+ hexColor(p.Border),172+ hexColor(p.TextDim),173+ )174+}175+176+func hexColor(c color.Color) string {177+ r, g, b, _ := c.RGBA()178+ return fmt.Sprintf("#%02x%02x%02x", uint8(r>>8), uint8(g>>8), uint8(b>>8))179+}180+181+func colorPtr(c color.Color) *string {182+ s := hexColor(c)183+ return &s184+}185+186+func boolPtr(v bool) *bool {187+ return &v188+}189+190+func stringPtr(v string) *string {191+ return &v192+}193+194+func uintPtr(v uint) *uint {195+ return &v196+}@@ -0,0 +1,92 @@
1+package app2+3+import (4+ "testing"5+6+ "github.com/FelineStateMachine/puzzletea/game"7+ "github.com/FelineStateMachine/puzzletea/theme"8+)9+10+func TestHelpMarkdownStyleUsesPaletteColors(t *testing.T) {11+ if err := theme.Apply(""); err != nil {12+ t.Fatalf("apply default theme: %v", err)13+ }14+ t.Cleanup(func() { _ = theme.Apply("") })15+16+ p := theme.Current()17+ style := helpMarkdownStyle(p)18+19+ if got := *style.H1.Color; got != hexColor(p.Accent) {20+ t.Fatalf("H1 color = %q, want %q", got, hexColor(p.Accent))21+ }22+ if got := *style.Code.StylePrimitive.BackgroundColor; got != hexColor(p.Surface) {23+ t.Fatalf("inline code background = %q, want %q", got, hexColor(p.Surface))24+ }25+ if got := *style.Link.Color; got != hexColor(p.Highlight) {26+ t.Fatalf("link color = %q, want %q", got, hexColor(p.Highlight))27+ }28+}29+30+func TestHelpMarkdownThemeKeyChangesWithTheme(t *testing.T) {31+ if err := theme.Apply(""); err != nil {32+ t.Fatalf("apply default theme: %v", err)33+ }34+ t.Cleanup(func() { _ = theme.Apply("") })35+36+ defaultKey := helpMarkdownThemeKey(theme.Current())37+38+ if err := theme.Apply("Dracula"); err != nil {39+ t.Fatalf("apply Dracula theme: %v", err)40+ }41+42+ draculaKey := helpMarkdownThemeKey(theme.Current())43+ if draculaKey == defaultKey {44+ t.Fatalf("theme key did not change across themes: %q", draculaKey)45+ }46+}47+48+func TestUpdateHelpDetailViewportRebuildsRendererAfterThemeChange(t *testing.T) {49+ if err := theme.Apply(""); err != nil {50+ t.Fatalf("apply default theme: %v", err)51+ }52+ t.Cleanup(func() { _ = theme.Apply("") })53+54+ m := model{55+ width: 100,56+ height: 30,57+ help: helpState{58+ category: game.Category{59+ Name: "Nonogram",60+ Help: "# Nonogram\n\n- clue\n",61+ },62+ },63+ }64+65+ m = m.updateHelpDetailViewport()66+ if m.help.renderer == nil {67+ t.Fatal("expected initial help renderer")68+ }69+70+ initialRenderer := m.help.renderer71+ initialThemeKey := m.help.rendererTheme72+73+ m = m.updateHelpDetailViewport()74+ if m.help.renderer != initialRenderer {75+ t.Fatal("expected help renderer to be reused when width and theme are unchanged")76+ }77+78+ if err := theme.Apply("Dracula"); err != nil {79+ t.Fatalf("apply Dracula theme: %v", err)80+ }81+82+ m = m.updateHelpDetailViewport()83+ if m.help.renderer == nil {84+ t.Fatal("expected help renderer after theme change")85+ }86+ if m.help.renderer == initialRenderer {87+ t.Fatal("expected help renderer to rebuild after theme change")88+ }89+ if m.help.rendererTheme == initialThemeKey {90+ t.Fatalf("renderer theme key = %q, want change from %q", m.help.rendererTheme, initialThemeKey)91+ }92+}@@ -0,0 +1,121 @@
1+package app2+3+import (4+ "strings"5+6+ "charm.land/bubbles/v2/list"7+ "github.com/FelineStateMachine/puzzletea/game"8+)9+10+type modeDisplayItem struct {11+ item list.Item12+ title string13+ description string14+ filterValue string15+}16+17+func (i modeDisplayItem) Title() string { return i.title }18+func (i modeDisplayItem) Description() string { return i.description }19+func (i modeDisplayItem) FilterValue() string { return i.filterValue }20+func (i modeDisplayItem) Original() list.Item { return i.item }21+22+func buildModeDisplayItems(cat game.Category) []list.Item {23+ titles := modeDisplayTitles(cat)24+ items := make([]list.Item, 0, len(cat.Modes))25+26+ for idx, item := range cat.Modes {27+ mode, ok := item.(game.Mode)28+ if !ok {29+ items = append(items, item)30+ continue31+ }32+33+ displayTitle := titles[idx]34+ filterValue := displayTitle + " " + mode.Description()35+ if displayTitle != mode.Title() {36+ filterValue += " " + mode.Title()37+ }38+39+ items = append(items, modeDisplayItem{40+ item: item,41+ title: displayTitle,42+ description: mode.Description(),43+ filterValue: strings.TrimSpace(filterValue),44+ })45+ }46+47+ return items48+}49+50+func modeDisplayTitles(cat game.Category) []string {51+ counts := make(map[string]int, len(cat.Modes))52+ titles := make([]string, 0, len(cat.Modes))53+54+ for _, item := range cat.Modes {55+ mode, ok := item.(game.Mode)56+ if !ok {57+ continue58+ }59+ base, _ := trimGridSizeSuffix(mode.Title())60+ counts[game.NormalizeName(base)]++61+ }62+63+ for _, item := range cat.Modes {64+ mode, ok := item.(game.Mode)65+ if !ok {66+ continue67+ }68+69+ title := mode.Title()70+ base, hadSize := trimGridSizeSuffix(title)71+ if hadSize && counts[game.NormalizeName(base)] == 1 {72+ title = base73+ }74+ titles = append(titles, title)75+ }76+77+ return titles78+}79+80+func unwrapModeDisplayItem(item list.Item) list.Item {81+ displayItem, ok := item.(interface{ Original() list.Item })82+ if !ok {83+ return item84+ }85+ return displayItem.Original()86+}87+88+func trimGridSizeSuffix(title string) (string, bool) {89+ fields := strings.Fields(title)90+ if len(fields) < 2 {91+ return title, false92+ }93+94+ if !isGridSizeToken(fields[len(fields)-1]) {95+ return title, false96+ }97+98+ base := strings.TrimSpace(strings.Join(fields[:len(fields)-1], " "))99+ if base == "" {100+ return title, false101+ }102+103+ return base, true104+}105+106+func isGridSizeToken(token string) bool {107+ width, height, found := strings.Cut(token, "x")108+ if !found || width == "" || height == "" {109+ return false110+ }111+ return digitsOnly(width) && digitsOnly(height)112+}113+114+func digitsOnly(s string) bool {115+ for _, r := range s {116+ if r < '0' || r > '9' {117+ return false118+ }119+ }120+ return true121+}@@ -0,0 +1,96 @@
1+package app2+3+import (4+ "testing"5+6+ "charm.land/bubbles/v2/list"7+ "github.com/FelineStateMachine/puzzletea/game"8+)9+10+type testMode struct {11+ game.BaseMode12+}13+14+func newTestMode(title string) testMode {15+ return testMode{BaseMode: game.NewBaseMode(title, title+" description")}16+}17+18+func TestModeDisplayTitlesStripUniqueGridSizes(t *testing.T) {19+ t.Parallel()20+21+ cat := game.Category{22+ Name: "Example",23+ Modes: []list.Item{24+ newTestMode("Mini 5x5"),25+ newTestMode("Hard 10x10"),26+ },27+ }28+29+ got := modeDisplayTitles(cat)30+ want := []string{"Mini", "Hard"}31+32+ if len(got) != len(want) {33+ t.Fatalf("len(modeDisplayTitles()) = %d, want %d", len(got), len(want))34+ }35+ for i := range want {36+ if got[i] != want[i] {37+ t.Fatalf("modeDisplayTitles()[%d] = %q, want %q", i, got[i], want[i])38+ }39+ }40+}41+42+func TestModeDisplayTitlesKeepDuplicateBaseNames(t *testing.T) {43+ t.Parallel()44+45+ cat := game.Category{46+ Name: "Example",47+ Modes: []list.Item{48+ newTestMode("Easy 5x5"),49+ newTestMode("Easy 10x10"),50+ newTestMode("Hard 10x10"),51+ },52+ }53+54+ got := modeDisplayTitles(cat)55+ want := []string{"Easy 5x5", "Easy 10x10", "Hard"}56+57+ if len(got) != len(want) {58+ t.Fatalf("len(modeDisplayTitles()) = %d, want %d", len(got), len(want))59+ }60+ for i := range want {61+ if got[i] != want[i] {62+ t.Fatalf("modeDisplayTitles()[%d] = %q, want %q", i, got[i], want[i])63+ }64+ }65+}66+67+func TestBuildModeDisplayItemsPreservesOriginalMode(t *testing.T) {68+ t.Parallel()69+70+ original := newTestMode("Hard 10x10")71+ cat := game.Category{72+ Name: "Example",73+ Modes: []list.Item{original},74+ }75+76+ items := buildModeDisplayItems(cat)77+ if len(items) != 1 {78+ t.Fatalf("len(buildModeDisplayItems()) = %d, want 1", len(items))79+ }80+81+ displayMode, ok := items[0].(game.Mode)82+ if !ok {83+ t.Fatal("buildModeDisplayItems()[0] does not implement game.Mode")84+ }85+ if got := displayMode.Title(); got != "Hard" {86+ t.Fatalf("display item Title() = %q, want %q", got, "Hard")87+ }88+89+ mode, ok := unwrapModeDisplayItem(items[0]).(game.Mode)90+ if !ok {91+ t.Fatal("unwrapModeDisplayItem() did not return a game.Mode")92+ }93+ if got := mode.Title(); got != "Hard 10x10" {94+ t.Fatalf("unwrapped mode Title() = %q, want %q", got, "Hard 10x10")95+ }96+}@@ -102,6 +102,7 @@ type helpState struct {
102102 viewport viewport.Model103103 renderer *glamour.TermRenderer104104 rendererWidth int105+ rendererTheme string105106 showFull bool106107 }107108 @@ -144,18 +144,23 @@ func (m model) updateCategoryDetailViewport() model {
144144 145145 func (m model) updateHelpDetailViewport() model {146146 helpWidth, helpHeight := helpViewportSize(m.width, m.height)147- if m.help.renderer == nil || m.help.rendererWidth != helpWidth {147+ palette := theme.Current()148+ themeKey := helpMarkdownThemeKey(palette)149+ if m.help.renderer == nil || m.help.rendererWidth != helpWidth || m.help.rendererTheme != themeKey {148150 renderer, err := glamour.NewTermRenderer(149- glamour.WithAutoStyle(),151+ glamour.WithStyles(helpMarkdownStyle(palette)),150152 glamour.WithWordWrap(helpWidth),153+ glamour.WithChromaFormatter("terminal16m"),151154 )152155 if err != nil {153156 log.Printf("failed to create help renderer: %v", err)154157 m.help.renderer = nil155158 m.help.rendererWidth = 0159+ m.help.rendererTheme = ""156160 } else {157161 m.help.renderer = renderer158162 m.help.rendererWidth = helpWidth163+ m.help.rendererTheme = themeKey159164 }160165 }161166 @@ -614,14 +619,14 @@ func (m model) handleGameSelectEnter() (tea.Model, tea.Cmd) {
614619 return m, nil615620 }616621 m.nav.selectedCategory = cat617- m.nav.modeSelectList = ui.InitList(cat.Modes, cat.Name+" - Select Mode")622+ m.nav.modeSelectList = ui.InitList(buildModeDisplayItems(cat), cat.Name+" - Select Mode")618623 m.nav.modeSelectList.SetSize(min(m.width, 64), min(m.height, ui.ListHeight(m.nav.modeSelectList)))619624 m.state = modeSelectView620625 return m, nil621626 }622627 623628 func (m model) handleModeSelectEnter() (tea.Model, tea.Cmd) {624- item := m.nav.modeSelectList.SelectedItem()629+ item := unwrapModeDisplayItem(m.nav.modeSelectList.SelectedItem())625630 mode, ok := item.(game.Mode)626631 if !ok {627632 return m, nil@@ -232,13 +232,10 @@ func renderModeList(cat game.Category, width int) string {
232232 return ui.DimItemStyle().Render("No modes available.")233233 }234234 235- modeNames := make([]string, 0, len(cat.Modes))236- for _, item := range cat.Modes {237- mode, ok := item.(game.Mode)238- if !ok {239- continue240- }241- modeNames = append(modeNames, "• "+mode.Title())235+ displayTitles := modeDisplayTitles(cat)236+ modeNames := make([]string, 0, len(displayTitles))237+ for _, title := range displayTitles {238+ modeNames = append(modeNames, "• "+title)242239 }243240 if len(modeNames) == 0 {244241 return ui.DimItemStyle().Render("No modes available.")@@ -14,6 +14,7 @@ import (
1414 "github.com/FelineStateMachine/puzzletea/rippleeffect"1515 "github.com/FelineStateMachine/puzzletea/shikaku"1616 "github.com/FelineStateMachine/puzzletea/sudoku"17+ "github.com/FelineStateMachine/puzzletea/sudokurgb"1718 "github.com/FelineStateMachine/puzzletea/takuzu"1819 "github.com/FelineStateMachine/puzzletea/takuzuplus"1920 "github.com/FelineStateMachine/puzzletea/wordsearch"@@ -30,6 +31,7 @@ var All = []game.Definition{
3031 rippleeffect.Definition,3132 shikaku.Definition,3233 sudoku.Definition,34+ sudokurgb.Definition,3335 takuzu.Definition,3436 takuzuplus.Definition,3537 wordsearch.Definition,@@ -19,6 +19,7 @@ func TestResolveSupportsCanonicalNamesAndAliases(t *testing.T) {
1919 want string2020 }{2121 {input: "Sudoku", want: "Sudoku"},22+ {input: "ripeto", want: "Sudoku RGB"},2223 {input: "fillomino", want: "Fillomino"},2324 {input: "hashi", want: "Hashiwokakero"},2425 {input: "lights", want: "Lights Out"},@@ -43,6 +44,7 @@ func TestCategorySupportsCanonicalNamesAndAliases(t *testing.T) {
4344 want string4445 }{4546 {input: "Sudoku", want: "Sudoku"},47+ {input: "sudoku-rgb", want: "Sudoku RGB"},4648 {input: "lights-out", want: "Lights Out"},4749 {input: "polyomino", want: "Fillomino"},4850 {input: "wordsearch", want: "Word Search"},@@ -202,3 +202,18 @@ func TestEligibleModesHaveMetadata(t *testing.T) {
202202 }203203 }204204 }205+206+func TestEligibleModesIncludeSudokuRGBDailyTiers(t *testing.T) {207+ found := map[string]bool{}208+ for _, entry := range eligibleModes {209+ if entry.GameType == "Sudoku RGB" {210+ found[entry.Mode] = true211+ }212+ }213+214+ for _, mode := range []string{"Easy", "Medium"} {215+ if !found[mode] {216+ t.Fatalf("eligibleModes missing Sudoku RGB %s", mode)217+ }218+ }219+}@@ -113,8 +113,14 @@ func (m Model) View() string {
113113 if m.solved {114114 return game.ComposeGameView(title, body)115115 }116+ info := cursorRegionInfoView(m)116117 status := statusBarView(m.showFullHelp)117- return game.ComposeGameViewRows(title, body, game.StableRow(status, statusBarView(false), statusBarView(true)))118+ return game.ComposeGameViewRows(119+ title,120+ body,121+ game.StableRow(info, cursorRegionInfoVariants(m)...),122+ game.StableRow(status, statusBarView(false), statusBarView(true)),123+ )118124 }119125 120126 func (m Model) SetTitle(t string) game.Gamer {@@ -474,6 +474,63 @@ func TestGridViewOmitsBorderBetweenAdjacentEmptyCells(t *testing.T) {
474474 }475475 }476476 477+func TestCursorRegionInfoViewNumberedCell(t *testing.T) {478+ m := Model{479+ width: 3,480+ height: 3,481+ grid: grid{{2, 2, 1}, {3, 0, 0}, {0, 0, 1}},482+ provided: newProvidedMask(grid{{2, 2, 1}, {3, 0, 0}, {0, 0, 1}}),483+ conflicts: validateGridState(grid{{2, 2, 1}, {3, 0, 0}, {0, 0, 1}}).conflicts,484+ cursor: pointCursor(0, 0),485+ }486+487+ if got, want := ansi.Strip(cursorRegionInfoView(m)), "region size: 2/2"; got != want {488+ t.Fatalf("cursorRegionInfoView() = %q, want %q", got, want)489+ }490+}491+492+func TestCursorRegionInfoViewEmptyRegion(t *testing.T) {493+ m := Model{494+ width: 3,495+ height: 3,496+ grid: grid{{2, 2, 0}, {3, 0, 0}, {0, 0, 1}},497+ provided: newProvidedMask(grid{{2, 2, 0}, {3, 0, 0}, {0, 0, 1}}),498+ conflicts: validateGridState(grid{{2, 2, 0}, {3, 0, 0}, {0, 0, 1}}).conflicts,499+ cursor: pointCursor(2, 1),500+ }501+502+ if got, want := ansi.Strip(cursorRegionInfoView(m)), "region size: 5/-"; got != want {503+ t.Fatalf("cursorRegionInfoView() = %q, want %q", got, want)504+ }505+}506+507+func TestViewKeepsStableHeightAcrossCursorRegionInfoChanges(t *testing.T) {508+ baseGrid := grid{509+ {2, 2, 1},510+ {3, 0, 0},511+ {0, 0, 1},512+ }513+ m := Model{514+ width: 3,515+ height: 3,516+ grid: baseGrid,517+ provided: newProvidedMask(baseGrid),518+ conflicts: validateGridState(baseGrid).conflicts,519+ modeTitle: "Test",520+ maxCellValue: 3,521+ }522+523+ numbered := m524+ numbered.cursor = pointCursor(0, 0)525+526+ empty := m527+ empty.cursor = pointCursor(2, 1)528+529+ if got, want := lipgloss.Height(empty.View()), lipgloss.Height(numbered.View()); got != want {530+ t.Fatalf("height(empty) = %d, want %d", got, want)531+ }532+}533+477534 func TestSolvedGapBackgroundUsesSolvedColor(t *testing.T) {478535 conflicts := validateGridState(grid{{2, 2}}).conflicts479536 m := Model{@@ -508,6 +508,47 @@ func completedRegionColor(comp component, colors []color.Color, base color.Color
508508 return theme.Blend(base, colors[index], 0.52)509509 }510510 511+func cursorRegionInfoStyle() lipgloss.Style {512+ return lipgloss.NewStyle().Foreground(theme.Current().Info)513+}514+515+func cursorRegionInfoView(m Model) string {516+ current, target := cursorRegionInfoData(m)517+ return cursorRegionInfoStyle().Render("region size: " + strconv.Itoa(current) + "/" + target)518+}519+520+func cursorRegionInfoVariants(m Model) []string {521+ area := m.width * m.height522+ maxTarget := m.maxCellValue523+ if maxTarget < 1 {524+ maxTarget = 1525+ }526+527+ return []string{528+ cursorRegionInfoStyle().Render("region size: " + strconv.Itoa(area) + "/-"),529+ cursorRegionInfoStyle().Render("region size: " + strconv.Itoa(area) + "/" + strconv.Itoa(maxTarget)),530+ }531+}532+533+func cursorRegionInfoData(m Model) (current int, target string) {534+ if len(m.grid) == 0 || len(m.grid[0]) == 0 {535+ return 0, "-"536+ }537+538+ cursor := point{x: m.cursor.X, y: m.cursor.Y}539+ value := m.grid[cursor.y][cursor.x]540+ visited := make([][]bool, len(m.grid))541+ for y := range len(m.grid) {542+ visited[y] = make([]bool, len(m.grid[y]))543+ }544+545+ comp := buildComponent(m.grid, cursor, visited)546+ if value == 0 {547+ return len(comp.cells), "-"548+ }549+ return len(comp.cells), strconv.Itoa(value)550+}551+511552 func statusBarView(showFullHelp bool) string {512553 if showFullHelp {513554 return game.StatusBarStyle().Render("1-9: place bkspc: clear arrows/wasd: move esc: menu ctrl+r: reset ctrl+h: help")@@ -11,7 +11,6 @@ import (
1111 const DynamicGridCellWidth = 31212 1313 const (14- dynamicGridCellStepX = DynamicGridCellWidth + 11514 dynamicGridCellStepY = 21615 )1716 @@ -42,6 +41,7 @@ type DynamicGridBridge struct {
4241 type DynamicGridSpec struct {4342 Width int4443 Height int44+ CellWidth int4545 Solved bool4646 Colors *GridBorderColors4747 Cell func(x, y int) string@@ -50,6 +50,8 @@ type DynamicGridSpec struct {
5050 HasHorizontalEdge func(x, y int) bool5151 ZoneFill func(zone int) color.Color5252 BridgeFill func(bridge DynamicGridBridge) color.Color53+ BridgeForeground func(bridge DynamicGridBridge) color.Color54+ BridgeBold func(bridge DynamicGridBridge) bool5355 VerticalBridgeText func(x, y int) string5456 HorizontalBridgeText func(x, y int) string5557 }@@ -64,6 +66,13 @@ func RenderDynamicGrid(spec DynamicGridSpec) string {
6466 return lipgloss.JoinVertical(lipgloss.Left, rows...)6567 }6668 69+func dynamicGridCellWidth(spec DynamicGridSpec) int {70+ if spec.CellWidth > 0 {71+ return spec.CellWidth72+ }73+ return DynamicGridCellWidth74+}75+6776 func DynamicGridOrigin(termW, termH int, fullView, title, grid string) (x, y int) {6877 viewWidth := lipgloss.Width(fullView)6978 viewHeight := lipgloss.Height(fullView)@@ -188,7 +197,15 @@ func dynamicGridRenderVerticalBridge(spec DynamicGridSpec, x, y int, bridge Dyna
188197 189198 if spec.VerticalBridgeText != nil {190199 if text := strings.TrimSpace(spec.VerticalBridgeText(x, y)); text != "" {191- return dynamicGridRenderBridgeText(text, 1, dynamicGridColors(spec), spec.Solved, dynamicGridBridgeBackground(spec, bridge))200+ return dynamicGridRenderBridgeText(201+ text,202+ 1,203+ dynamicGridColors(spec),204+ spec.Solved,205+ dynamicGridBridgeBackground(spec, bridge),206+ dynamicGridBridgeForeground(spec, bridge),207+ dynamicGridBridgeBold(spec, bridge),208+ )192209 }193210 }194211 @@ -201,6 +218,7 @@ func dynamicGridRenderVerticalBridge(spec DynamicGridSpec, x, y int, bridge Dyna
201218 }202219 203220 func dynamicGridRenderHorizontalBridge(spec DynamicGridSpec, x, y int, bridge DynamicGridBridge) string {221+ cellWidth := dynamicGridCellWidth(spec)204222 if dynamicGridHasHorizontalEdge(spec, x, y) {205223 return strings.Repeat(206224 dynamicGridRenderBorderChar(@@ -209,13 +227,21 @@ func dynamicGridRenderHorizontalBridge(spec DynamicGridSpec, x, y int, bridge Dy
209227 spec.Solved,210228 dynamicGridBridgeBackground(spec, bridge),211229 ),212- DynamicGridCellWidth,230+ cellWidth,213231 )214232 }215233 216234 if spec.HorizontalBridgeText != nil {217235 if text := strings.TrimSpace(spec.HorizontalBridgeText(x, y)); text != "" {218- return dynamicGridRenderBridgeText(text, DynamicGridCellWidth, dynamicGridColors(spec), spec.Solved, dynamicGridBridgeBackground(spec, bridge))236+ return dynamicGridRenderBridgeText(237+ text,238+ cellWidth,239+ dynamicGridColors(spec),240+ spec.Solved,241+ dynamicGridBridgeBackground(spec, bridge),242+ dynamicGridBridgeForeground(spec, bridge),243+ dynamicGridBridgeBold(spec, bridge),244+ )219245 }220246 }221247 @@ -226,19 +252,33 @@ func dynamicGridRenderHorizontalBridge(spec DynamicGridSpec, x, y int, bridge Dy
226252 spec.Solved,227253 dynamicGridBridgeBackground(spec, bridge),228254 ),229- DynamicGridCellWidth,255+ cellWidth,230256 )231257 }232258 233-func dynamicGridRenderBridgeText(text string, width int, colors GridBorderColors, solved bool, bg color.Color) string {259+func dynamicGridRenderBridgeText(260+ text string,261+ width int,262+ colors GridBorderColors,263+ solved bool,264+ bg color.Color,265+ fgOverride color.Color,266+ bold bool,267+) string {234268 fg, bg := borderColors(colors, solved, bg)269+ if fgOverride != nil {270+ fg = fgOverride271+ }235272 236- return lipgloss.NewStyle().273+ style := lipgloss.NewStyle().237274 Width(width).238275 AlignHorizontal(lipgloss.Center).239276 Foreground(fg).240- Background(bg).241- Render(text)277+ Background(bg)278+ if bold {279+ style = style.Bold(true)280+ }281+ return style.Render(text)242282 }243283 244284 func dynamicGridHasHorizontalEdge(spec DynamicGridSpec, x, y int) bool {@@ -313,6 +353,20 @@ func dynamicGridBridgeBackground(spec DynamicGridSpec, bridge DynamicGridBridge)
313353 return nil314354 }315355 356+func dynamicGridBridgeForeground(spec DynamicGridSpec, bridge DynamicGridBridge) color.Color {357+ if spec.BridgeForeground != nil {358+ return spec.BridgeForeground(bridge)359+ }360+ return nil361+}362+363+func dynamicGridBridgeBold(spec DynamicGridSpec, bridge DynamicGridBridge) bool {364+ if spec.BridgeBold != nil {365+ return spec.BridgeBold(bridge)366+ }367+ return false368+}369+316370 func dynamicGridVerticalBridge(spec DynamicGridSpec, x, y int) DynamicGridBridge {317371 if dynamicGridHasVerticalEdge(spec, x, y) || x <= 0 || x >= spec.Width {318372 return DynamicGridBridge{@@ -79,6 +79,34 @@ func TestRenderDynamicGridUsesUniformRows(t *testing.T) {
7979 }8080 }8181 82+func TestRenderDynamicGridSupportsCustomCellWidth(t *testing.T) {83+ view := RenderDynamicGrid(DynamicGridSpec{84+ Width: 2,85+ Height: 1,86+ CellWidth: 5,87+ Cell: func(_, _ int) string {88+ return " "89+ },90+ ZoneAt: func(_, _ int) int {91+ return 092+ },93+ VerticalBridgeText: func(x, y int) string {94+ if x == 1 && y == 0 {95+ return "x"96+ }97+ return ""98+ },99+ })100+101+ lines := strings.Split(ansi.Strip(view), "\n")102+ if got := len([]rune(lines[0])); got != 13 {103+ t.Fatalf("top row width = %d, want %d", got, 13)104+ }105+ if !strings.Contains(lines[1], "x") {106+ t.Fatalf("content row = %q, want custom-width bridge text", lines[1])107+ }108+}109+82110 func TestRenderDynamicGridEdgeOverridesSuppressInteriorBorders(t *testing.T) {83111 view := RenderDynamicGrid(DynamicGridSpec{84112 Width: 2,@@ -18,6 +18,11 @@ type Model struct {
1818 keys KeyMap1919 modeTitle string2020 showFullHelp bool21+ termWidth int22+ termHeight int23+ originX int24+ originY int25+ originValid bool2126 }2227 2328 func New(mode HashiMode, puzzle Puzzle) game.Gamer {@@ -41,6 +46,19 @@ func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {
4146 switch msg := msg.(type) {4247 case game.HelpToggleMsg:4348 m.showFullHelp = msg.Show49+ m.originValid = false50+ case tea.WindowSizeMsg:51+ m.termWidth = msg.Width52+ m.termHeight = msg.Height53+ m.originValid = false54+ case tea.MouseClickMsg:55+ if msg.Button == tea.MouseLeft {56+ if col, row, ok := m.screenToGrid(msg.X, msg.Y); ok {57+ if isl := m.puzzle.FindIslandAt(col, row); isl != nil {58+ m.cursorIsland = isl.ID59+ }60+ }61+ }4462 case tea.KeyPressMsg:4563 if m.selectedIsland != nil {4664 m = m.handleBridgeMode(msg)@@ -122,6 +140,7 @@ func (m Model) cycleBridge(dx, dy int) Model {
122140 m.puzzle.SetBridge(*m.selectedIsland, adj.ID, newCount)123141 if m.puzzle.IsSolved() {124142 m.selectedIsland = nil143+ m.originValid = false125144 }126145 return m127146 }@@ -138,13 +157,12 @@ func (m Model) View() string {
138157 139158 selected := m.selectedIsland != nil140159 status := statusBarView(selected, m.showFullHelp)141-142- return game.ComposeGameViewRows(143- title,144- grid,160+ rows := []game.SecondaryRow{145161 game.StaticRow(info),146162 game.StableRow(status, statusBarVariants()...),147- )163+ }164+165+ return game.ComposeGameViewRows(title, grid, rows...)148166 }149167 150168 func (m Model) SetTitle(t string) game.Gamer {@@ -164,6 +182,7 @@ func (m Model) Reset() game.Gamer {
164182 m.cursorIsland = m.puzzle.Islands[0].ID165183 }166184 m.selectedIsland = nil185+ m.originValid = false167186 return m168187 }169188 @@ -2,10 +2,16 @@ package hashiwokakero
22 33 import (44 "encoding/json"5+ "image"6+ "image/color"7+ "strings"58 "testing"69 710 tea "charm.land/bubbletea/v2"811 "charm.land/lipgloss/v2"12+ "github.com/FelineStateMachine/puzzletea/game"13+ "github.com/FelineStateMachine/puzzletea/theme"14+ "github.com/charmbracelet/x/ansi"915 )1016 1117 // --- helpers ---@@ -381,6 +387,280 @@ func TestViewHeightStableAcrossStatusModes(t *testing.T) {
381387 }382388 }383389 390+func TestScreenToGridUsesHashiCellWidth(t *testing.T) {391+ m := Model{puzzle: *fourCornerPuzzle(), cursorIsland: 0, keys: DefaultKeyMap, modeTitle: "Test"}392+393+ next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})394+ got := next.(Model)395+ x, y := got.cachedGridOrigin()396+397+ if col, row, ok := got.screenToGrid(x, y); !ok || col != 0 || row != 0 {398+ t.Fatalf("screenToGrid(origin) = (%d,%d,%v), want (0,0,true)", col, row, ok)399+ }400+ if _, _, ok := got.screenToGrid(x+cellWidth, y); ok {401+ t.Fatal("screenToGrid(separator) = ok, want false")402+ }403+}404+405+func TestMouseClickMovesCursorToClickedIsland(t *testing.T) {406+ m := Model{puzzle: *fourCornerPuzzle(), cursorIsland: 0, keys: DefaultKeyMap, modeTitle: "Test"}407+408+ next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})409+ got := next.(Model)410+ ox, oy := got.cachedGridOrigin()411+412+ clickX := ox + (cellWidth+1)*3413+ clickY := oy414+ next, _ = got.Update(tea.MouseClickMsg{X: clickX, Y: clickY, Button: tea.MouseLeft})415+ clicked := next.(Model)416+417+ if clicked.cursorIsland != 1 {418+ t.Fatalf("cursorIsland = %d, want %d", clicked.cursorIsland, 1)419+ }420+}421+422+func TestMouseClickIgnoresEmptyCells(t *testing.T) {423+ m := Model{puzzle: *fourCornerPuzzle(), cursorIsland: 0, keys: DefaultKeyMap, modeTitle: "Test"}424+425+ next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})426+ got := next.(Model)427+ ox, oy := got.cachedGridOrigin()428+429+ clickX := ox + (cellWidth + 1)430+ clickY := oy431+ next, _ = got.Update(tea.MouseClickMsg{X: clickX, Y: clickY, Button: tea.MouseLeft})432+ clicked := next.(Model)433+434+ if clicked.cursorIsland != 0 {435+ t.Fatalf("cursorIsland = %d, want unchanged 0", clicked.cursorIsland)436+ }437+}438+439+func TestGridViewUsesOnlyOuterBorders(t *testing.T) {440+ m := Model{puzzle: *fourCornerPuzzle(), cursorIsland: 0, keys: DefaultKeyMap}441+442+ lines := strings.Split(ansi.Strip(gridView(m, false)), "\n")443+ if got, want := len(lines), 9; got != want {444+ t.Fatalf("line count = %d, want %d", got, want)445+ }446+447+ if !strings.HasPrefix(lines[0], "┌") || !strings.HasSuffix(lines[0], "┐") {448+ t.Fatalf("top border = %q, want outer corners", lines[0])449+ }450+ if !strings.HasPrefix(lines[len(lines)-1], "└") || !strings.HasSuffix(lines[len(lines)-1], "┘") {451+ t.Fatalf("bottom border = %q, want outer corners", lines[len(lines)-1])452+ }453+454+ for _, idx := range []int{1, 3, 5, 7} {455+ if got := strings.Count(lines[idx], "│"); got != 2 {456+ t.Fatalf("content row %d = %q, want only outer vertical borders", idx, lines[idx])457+ }458+ }459+ for _, idx := range []int{2, 4, 6} {460+ if strings.Contains(lines[idx], "─") {461+ t.Fatalf("separator row %d = %q, want no interior horizontal border glyphs", idx, lines[idx])462+ }463+ }464+}465+466+func TestGridViewRendersSingleAndDoubleHorizontalBridgesAcrossSeparators(t *testing.T) {467+ makePuzzle := func(count int) Puzzle {468+ p := Puzzle{469+ Width: 3,470+ Height: 1,471+ Islands: []Island{472+ {ID: 0, X: 0, Y: 0, Required: count},473+ {ID: 1, X: 2, Y: 0, Required: count},474+ },475+ }476+ p.SetBridge(0, 1, count)477+ return p478+ }479+480+ single := Model{puzzle: makePuzzle(1), cursorIsland: 0, keys: DefaultKeyMap}481+ singleRow := strings.Split(ansi.Strip(gridView(single, false)), "\n")[1]482+ if got := strings.Count(singleRow, "━"); got != 7 {483+ t.Fatalf("single bridge row = %q, want 7 heavy horizontal bridge glyphs including separators", singleRow)484+ }485+486+ double := Model{puzzle: makePuzzle(2), cursorIsland: 0, keys: DefaultKeyMap}487+ doubleRow := strings.Split(ansi.Strip(gridView(double, false)), "\n")[1]488+ if got := strings.Count(doubleRow, "═"); got != 7 {489+ t.Fatalf("double bridge row = %q, want 7 double-bridge glyphs including separators", doubleRow)490+ }491+}492+493+func TestGridViewRendersVerticalBridgeInSeparatorRows(t *testing.T) {494+ p := Puzzle{495+ Width: 1,496+ Height: 3,497+ Islands: []Island{498+ {ID: 0, X: 0, Y: 0, Required: 1},499+ {ID: 1, X: 0, Y: 2, Required: 1},500+ },501+ }502+ p.SetBridge(0, 1, 1)503+504+ m := Model{puzzle: p, cursorIsland: 0, keys: DefaultKeyMap}505+ lines := strings.Split(ansi.Strip(gridView(m, false)), "\n")506+507+ if !strings.Contains(lines[2], " ┃ ") {508+ t.Fatalf("upper separator row = %q, want vertical bridge glyph", lines[2])509+ }510+ if !strings.Contains(lines[3], " ┃ ") {511+ t.Fatalf("bridge cell row = %q, want vertical bridge glyph", lines[3])512+ }513+ if !strings.Contains(lines[4], " ┃ ") {514+ t.Fatalf("lower separator row = %q, want vertical bridge glyph", lines[4])515+ }516+}517+518+func TestResolveCellVisualCursorIsland(t *testing.T) {519+ m := Model{puzzle: *fourCornerPuzzle(), cursorIsland: 0, keys: DefaultKeyMap}520+521+ got := renderCellVisual(resolveCellVisual(m, 0, 0, false))522+ want := renderExpectedIslandCell(game.CursorLeft+"2"+game.CursorRight, game.CursorFG(), game.CursorBG(), true)523+ if got != want {524+ t.Fatalf("cursor island render = %q, want %q", got, want)525+ }526+}527+528+func TestResolveCellVisualSelectedIsland(t *testing.T) {529+ selected := 0530+ m := Model{puzzle: *fourCornerPuzzle(), cursorIsland: 1, selectedIsland: &selected, keys: DefaultKeyMap}531+532+ got := renderCellVisual(resolveCellVisual(m, 0, 0, false))533+ want := renderExpectedIslandCell(" 2 ", theme.TextOnBG(theme.Current().SelectionBG), theme.Current().SelectionBG, true)534+ if got != want {535+ t.Fatalf("selected island render = %q, want %q", got, want)536+ }537+}538+539+func TestResolveCellVisualSatisfiedAndOverfilledIslands(t *testing.T) {540+ satisfied := Puzzle{541+ Width: 3,542+ Height: 1,543+ Islands: []Island{544+ {ID: 0, X: 0, Y: 0, Required: 1},545+ {ID: 1, X: 2, Y: 0, Required: 1},546+ },547+ }548+ satisfied.SetBridge(0, 1, 1)549+ satisfiedModel := Model{puzzle: satisfied, cursorIsland: 1, keys: DefaultKeyMap}550+551+ gotSatisfied := renderCellVisual(resolveCellVisual(satisfiedModel, 0, 0, false))552+ wantSatisfied := renderExpectedIslandCell(" 1 ", theme.TextOnBG(theme.Current().SuccessBG), theme.Current().SuccessBG, true)553+ if gotSatisfied != wantSatisfied {554+ t.Fatalf("satisfied island render = %q, want %q", gotSatisfied, wantSatisfied)555+ }556+557+ overfilled := Puzzle{558+ Width: 5,559+ Height: 3,560+ Islands: []Island{561+ {ID: 0, X: 0, Y: 1, Required: 2},562+ {ID: 1, X: 2, Y: 1, Required: 2},563+ {ID: 2, X: 4, Y: 1, Required: 2},564+ {ID: 3, X: 2, Y: 0, Required: 1},565+ },566+ }567+ overfilled.SetBridge(0, 1, 1)568+ overfilled.SetBridge(1, 2, 1)569+ overfilled.SetBridge(1, 3, 1)570+ overfilledModel := Model{puzzle: overfilled, cursorIsland: 0, keys: DefaultKeyMap}571+572+ gotOverfilled := renderCellVisual(resolveCellVisual(overfilledModel, 2, 1, false))573+ wantOverfilled := renderExpectedIslandCell(" 2 ", theme.TextOnBG(theme.Current().ErrorBG), theme.Current().ErrorBG, true)574+ if gotOverfilled != wantOverfilled {575+ t.Fatalf("overfilled island render = %q, want %q", gotOverfilled, wantOverfilled)576+ }577+}578+579+func TestSolvedBridgeVisualStaysLegibleOverBlueBoard(t *testing.T) {580+ p := Puzzle{581+ Width: 3,582+ Height: 1,583+ Islands: []Island{584+ {ID: 0, X: 0, Y: 0, Required: 1},585+ {ID: 1, X: 2, Y: 0, Required: 1},586+ },587+ }588+ p.SetBridge(0, 1, 1)589+ m := Model{puzzle: p, cursorIsland: 0, keys: DefaultKeyMap}590+591+ empty := resolveCellVisual(m, 0, 0, true)592+ if !sameColor(empty.outerBG, boardBackground()) {593+ t.Fatal("expected solved island outer background to stay on the board color")594+ }595+596+ bridge := resolveCellVisual(m, 1, 0, true)597+ if !sameColor(bridge.bg, boardBackground()) {598+ t.Fatal("expected solved bridge cell to keep the blue board background")599+ }600+ if !sameColor(bridge.fg, bridgeColor(0)) {601+ t.Fatal("expected solved bridge cell to use the bridge palette color")602+ }603+}604+605+func TestBridgeForegroundUsesThemePalettePerBridge(t *testing.T) {606+ p := Puzzle{607+ Width: 5,608+ Height: 1,609+ Islands: []Island{610+ {ID: 0, X: 0, Y: 0, Required: 1},611+ {ID: 1, X: 2, Y: 0, Required: 2},612+ {ID: 2, X: 4, Y: 0, Required: 1},613+ },614+ }615+ p.SetBridge(0, 1, 1)616+ p.SetBridge(1, 2, 1)617+ m := Model{puzzle: p, cursorIsland: 0, keys: DefaultKeyMap}618+619+ leftBridge := game.DynamicGridBridge{620+ Kind: game.DynamicGridBridgeVertical,621+ X: 1,622+ Y: 0,623+ Count: 2,624+ Cells: [4]image.Point{{X: 0, Y: 0}, {X: 1, Y: 0}},625+ }626+ rightBridge := game.DynamicGridBridge{627+ Kind: game.DynamicGridBridgeVertical,628+ X: 3,629+ Y: 0,630+ Count: 2,631+ Cells: [4]image.Point{{X: 2, Y: 0}, {X: 3, Y: 0}},632+ }633+634+ if !sameColor(bridgeForeground(m, leftBridge), bridgeColor(0)) {635+ t.Fatal("expected first bridge to use the first palette color")636+ }637+ if !sameColor(bridgeForeground(m, rightBridge), bridgeColor(1)) {638+ t.Fatal("expected second bridge to use the second palette color")639+ }640+}641+642+func sameColor(left, right color.Color) bool {643+ if left == nil || right == nil {644+ return left == nil && right == nil645+ }646+ lr, lg, lb, la := left.RGBA()647+ rr, rg, rb, ra := right.RGBA()648+ return lr == rr && lg == rg && lb == rb && la == ra649+}650+651+func renderExpectedIslandCell(text string, fg, bg color.Color, bold bool) string {652+ side := lipgloss.NewStyle().Width((cellWidth - islandPillWidth) / 2).Background(boardBackground()).Render("")653+ center := lipgloss.NewStyle().654+ Width(islandPillWidth).655+ AlignHorizontal(lipgloss.Center).656+ Foreground(fg).657+ Background(bg)658+ if bold {659+ center = center.Bold(true)660+ }661+ return side + center.Render(text) + side662+}663+384664 // --- IsConnected (P0) ---385665 386666 func TestIsConnected(t *testing.T) {@@ -0,0 +1,37 @@
1+package hashiwokakero2+3+import "github.com/FelineStateMachine/puzzletea/game"4+5+func (m *Model) screenToGrid(screenX, screenY int) (col, row int, ok bool) {6+ ox, oy := m.cachedGridOrigin()7+ return game.DynamicGridScreenToCell(8+ game.DynamicGridMetrics{9+ Width: m.puzzle.Width,10+ Height: m.puzzle.Height,11+ CellWidth: cellWidth,12+ },13+ ox,14+ oy,15+ screenX,16+ screenY,17+ false,18+ )19+}20+21+func (m *Model) cachedGridOrigin() (x, y int) {22+ if m.originValid {23+ return m.originX, m.originY24+ }25+26+ x, y = m.gridOrigin()27+ m.originX, m.originY = x, y28+ m.originValid = true29+ return x, y30+}31+32+func (m *Model) gridOrigin() (x, y int) {33+ solved := m.puzzle.IsSolved()34+ title := game.TitleBarView("Hashiwokakero", m.modeTitle, solved)35+ grid := gridView(*m, solved)36+ return game.DynamicGridOrigin(m.termWidth, m.termHeight, m.View(), title, grid)37+}@@ -10,84 +10,97 @@ import (
1010 "github.com/FelineStateMachine/puzzletea/theme"1111 )1212 13-const cellWidth = 313+const cellWidth = 514+const islandPillWidth = game.DynamicGridCellWidth15+16+type cellVisual struct {17+ text string18+ fg color.Color19+ bg color.Color20+ outerBG color.Color21+ bold bool22+ pill bool23+}24+25+func boardBackground() color.Color {26+ return theme.Current().BG27+}28+29+func baseIslandBackground() color.Color {30+ p := theme.Current()31+ return theme.Blend(p.BG, p.Success, 0.45)32+}33+34+func adjacentIslandBackground() color.Color {35+ return theme.Blend(baseIslandBackground(), theme.Current().Highlight, 0.12)36+}37+38+func solvedIslandBackground() color.Color {39+ return theme.Blend(baseIslandBackground(), theme.Current().Success, 0.18)40+}1441 15-func islandColors() []color.Color { return theme.Current().ThemeColors() }42+func baseCellStyle(fg, bg color.Color, bold bool) lipgloss.Style {43+ style := lipgloss.NewStyle().44+ Width(cellWidth).45+ AlignHorizontal(lipgloss.Center).46+ Foreground(fg).47+ Background(bg)48+ if bold {49+ style = style.Bold(true)50+ }51+ return style52+}1653 17-func islandDefaultStyle(islandID int) lipgloss.Style {18- colors := islandColors()19- bg := colors[islandID%len(colors)]54+func selectedIslandStyle() lipgloss.Style {55+ bg := theme.Current().SelectionBG2056 return lipgloss.NewStyle().57+ Width(islandPillWidth).58+ AlignHorizontal(lipgloss.Center).2159 Foreground(theme.TextOnBG(bg)).2260 Background(bg).2361 Bold(true)2462 }2563 26-func islandSatisfiedStyle() lipgloss.Style {64+func satisfiedIslandStyle() lipgloss.Style {2765 p := theme.Current()2866 return lipgloss.NewStyle().29- Foreground(p.SuccessBorder).67+ Width(islandPillWidth).68+ AlignHorizontal(lipgloss.Center).69+ Foreground(theme.TextOnBG(p.SuccessBG)).3070 Background(p.SuccessBG).3171 Bold(true)3272 }3373 34-func islandOverStyle() lipgloss.Style {74+func overfilledIslandStyle() lipgloss.Style {3575 p := theme.Current()3676 return lipgloss.NewStyle().37- Foreground(p.Error).77+ Width(islandPillWidth).78+ AlignHorizontal(lipgloss.Center).79+ Foreground(theme.TextOnBG(p.ErrorBG)).3880 Background(p.ErrorBG).3981 Bold(true)4082 }4183 42-// islandHighlightBGStyle returns the style for adjacent/neighbor islands.43-44-func islandAdjacentStyle(islandID int) lipgloss.Style {45- colors := islandColors()46- bg := colors[islandID%len(colors)]84+func adjacentIslandStyle() lipgloss.Style {85+ bg := adjacentIslandBackground()4786 return lipgloss.NewStyle().87+ Width(islandPillWidth).88+ AlignHorizontal(lipgloss.Center).4889 Foreground(theme.TextOnBG(bg)).4990 Background(bg).5091 Bold(true)5192 }5293 53-func bridgeStyle() lipgloss.Style {54- return lipgloss.NewStyle().55- Foreground(theme.Current().Linked)56-}57-58-func bridgeSolvedStyle() lipgloss.Style {59- return lipgloss.NewStyle().60- Foreground(theme.Current().SuccessBorder)61-}62-63-func emptyDotStyle() lipgloss.Style {64- return lipgloss.NewStyle().65- Foreground(theme.Current().TextDim)66-}67-68-func gridBorderStyle() lipgloss.Style {69- return lipgloss.NewStyle().70- Border(lipgloss.RoundedBorder()).71- Padding(1).72- BorderForeground(theme.Current().TextDim)73-}74-75-func gridBorderSolvedStyle() lipgloss.Style {76- return lipgloss.NewStyle().77- Border(lipgloss.RoundedBorder()).78- Padding(1).79- BorderForeground(theme.Current().SuccessBorder).80- BorderBackground(theme.Current().SuccessBG)94+func bridgeColors() []color.Color {95+ return theme.Current().ThemeColors()8196 }8297 83-// islandSelectedStyle returns the style for a selected island, using the84-// current theme's Highlight color for the background.85-func islandSelectedStyle() lipgloss.Style {86- p := theme.Current()87- return lipgloss.NewStyle().88- Foreground(p.Highlight).89- Background(p.SelectionBG).90- Bold(true)98+func bridgeColor(index int) color.Color {99+ colors := bridgeColors()100+ if index < 0 || len(colors) == 0 {101+ return theme.TextOnBG(boardBackground())102+ }103+ return colors[index%len(colors)]91104 }92105 93106 // isHighlightedNeighbor returns true if islandID is directly connectable@@ -109,182 +122,275 @@ func isHighlightedNeighbor(m Model, islandID int) bool {
109122 return false110123 }111124 112-// emptyCellView renders an empty grid cell as a subtle dot.113-func emptyCellView(solved bool) string {114- p := theme.Current()115- s := emptyDotStyle()116- if solved {117- s = s.Foreground(p.SolvedFG).Background(p.SuccessBG)125+func resolveCellVisual(m Model, x, y int, solved bool) cellVisual {126+ boardBG := boardBackground()127+ visual := cellVisual{128+ text: " ",129+ fg: theme.TextOnBG(boardBG),130+ bg: boardBG,131+ outerBG: boardBG,118132 }119- return s.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render("\u00b7")120-}121133 122-// gridView renders the puzzle using a display grid with gap cells between123-// logical cells so that bridges between directly adjacent islands are visible.124-// Display grid is (2*width-1) x (2*height-1): even coords are logical cells,125-// odd coords are gap cells showing bridge connectors.126-func gridView(m Model, solved bool) string {127- dispW := 2*m.puzzle.Width - 1128- dispH := 2*m.puzzle.Height - 1129-130- var rows []string131- for dy := range dispH {132- var cells []string133- for dx := range dispW {134- cells = append(cells, displayCellView(m, dx, dy, solved))134+ ci := m.puzzle.CellContent(x, y)135+ switch ci.Kind {136+ case cellIsland:137+ isl := m.puzzle.FindIslandByID(ci.IslandID)138+ if isl == nil {139+ return visual140+ }141+142+ label := fmt.Sprintf(" %d ", isl.Required)143+ isCursor := m.cursorIsland == ci.IslandID144+ current := m.puzzle.BridgeCount(ci.IslandID)145+ islandBG := baseIslandBackground()146+ visual.pill = true147+ visual.outerBG = boardBG148+149+ switch {150+ case solved && isCursor:151+ visual.fg = game.CursorFG()152+ visual.bg = theme.Current().SuccessBG153+ visual.bold = true154+ case solved:155+ islandBG = solvedIslandBackground()156+ visual.fg = theme.TextOnBG(islandBG)157+ visual.bg = islandBG158+ visual.bold = true159+ case m.selectedIsland != nil && *m.selectedIsland == ci.IslandID:160+ visual.fg = theme.TextOnBG(theme.Current().SelectionBG)161+ visual.bg = theme.Current().SelectionBG162+ visual.bold = true163+ case isCursor:164+ visual.fg = game.CursorFG()165+ visual.bg = game.CursorBG()166+ visual.bold = true167+ case current > isl.Required:168+ visual.fg = theme.TextOnBG(theme.Current().ErrorBG)169+ visual.bg = theme.Current().ErrorBG170+ visual.bold = true171+ case current == isl.Required:172+ visual.fg = theme.TextOnBG(theme.Current().SuccessBG)173+ visual.bg = theme.Current().SuccessBG174+ visual.bold = true175+ case isHighlightedNeighbor(m, ci.IslandID):176+ adjacentBG := adjacentIslandBackground()177+ visual.fg = theme.TextOnBG(adjacentBG)178+ visual.bg = adjacentBG179+ visual.bold = true180+ default:181+ visual.fg = theme.TextOnBG(islandBG)182+ visual.bg = islandBG183+ visual.bold = true135184 }136- rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, cells...))137- }138- grid := lipgloss.JoinVertical(lipgloss.Left, rows...)139185 140- if solved {141- return gridBorderSolvedStyle().Render(grid)186+ if isCursor && !solved {187+ label = game.CursorLeft + fmt.Sprintf("%d", isl.Required) + game.CursorRight188+ }189+ visual.text = label190+191+ case cellBridgeH:192+ visual.text = horizontalBridgeGlyph(ci.BridgeCount)193+ visual.fg = bridgeColor(ci.BridgeIdx)194+ visual.bold = ci.BridgeCount == 2195+ case cellBridgeV:196+ visual.text = verticalBridgeGlyph(ci.BridgeCount)197+ visual.fg = bridgeColor(ci.BridgeIdx)198+ visual.bold = ci.BridgeCount == 2142199 }143- return gridBorderStyle().Render(grid)200+201+ return visual144202 }145203 146-// displayCellView renders a single cell in the display grid.147-func displayCellView(m Model, dx, dy int, solved bool) string {148- evenX := dx%2 == 0149- evenY := dy%2 == 0150-151- if evenX && evenY {152- // Logical cell position153- lx, ly := dx/2, dy/2154- ci := m.puzzle.CellContent(lx, ly)155- switch ci.Kind {156- case cellIsland:157- return islandView(m, ci.IslandID, solved)158- case cellBridgeH:159- return bridgeHView(ci.BridgeCount, solved)160- case cellBridgeV:161- return bridgeVView(ci.BridgeCount, solved)162- default:163- return emptyCellView(solved)204+func renderCellVisual(visual cellVisual) string {205+ if visual.pill {206+ sideWidth := max((cellWidth-islandPillWidth)/2, 0)207+ left := lipgloss.NewStyle().Width(sideWidth).Background(visual.outerBG).Render("")208+ right := lipgloss.NewStyle().Width(cellWidth - islandPillWidth - sideWidth).Background(visual.outerBG).Render("")209+ center := lipgloss.NewStyle().210+ Width(islandPillWidth).211+ AlignHorizontal(lipgloss.Center).212+ Foreground(visual.fg).213+ Background(visual.bg)214+ if visual.bold {215+ center = center.Bold(true)164216 }217+ return left + center.Render(visual.text) + right165218 }219+ return baseCellStyle(visual.fg, visual.bg, visual.bold).Render(visual.text)220+}166221 167- if !evenX && evenY {168- // Horizontal gap between (dx/2, dy/2) and (dx/2+1, dy/2)169- lx, ly := dx/2, dy/2170- return hGapView(m, lx, ly, solved)171- }222+func gridView(m Model, solved bool) string {223+ return game.RenderDynamicGrid(game.DynamicGridSpec{224+ Width: m.puzzle.Width,225+ Height: m.puzzle.Height,226+ CellWidth: cellWidth,227+ Solved: solved,228+ Cell: func(x, y int) string {229+ return renderCellVisual(resolveCellVisual(m, x, y, solved))230+ },231+ ZoneAt: func(_, _ int) int {232+ return 0233+ },234+ HasVerticalEdge: func(x, _ int) bool {235+ return x <= 0 || x >= m.puzzle.Width236+ },237+ HasHorizontalEdge: func(_, y int) bool {238+ return y <= 0 || y >= m.puzzle.Height239+ },240+ BridgeForeground: func(bridge game.DynamicGridBridge) color.Color {241+ return bridgeForeground(m, bridge)242+ },243+ BridgeBold: func(bridge game.DynamicGridBridge) bool {244+ return bridgeBold(m, bridge)245+ },246+ VerticalBridgeText: func(x, y int) string {247+ return horizontalSeparatorBridge(m, x, y)248+ },249+ HorizontalBridgeText: func(x, y int) string {250+ return verticalSeparatorBridge(m, x, y)251+ },252+ })253+}172254 173- if evenX && !evenY {174- // Vertical gap between (dx/2, dy/2) and (dx/2, dy/2+1)175- lx, ly := dx/2, dy/2176- return vGapView(m, lx, ly, solved)255+func horizontalBridgeGlyph(count int) string {256+ if count == 2 {257+ return "═════"177258 }178-179- // Odd x and odd y — intersection of gaps, always empty180- return lipgloss.NewStyle().Width(cellWidth).Render(" ")259+ return "━━━━━"181260 }182261 183-// hGapView renders the horizontal gap between logical cell (lx,ly) and (lx+1,ly).184-// Shows a bridge connector if both neighbors are islands connected by a bridge.185-func hGapView(m Model, lx, ly int, solved bool) string {186- left := m.puzzle.FindIslandAt(lx, ly)187- right := m.puzzle.FindIslandAt(lx+1, ly)188- if left != nil && right != nil {189- if b := m.puzzle.GetBridge(left.ID, right.ID); b != nil {190- return bridgeHView(b.Count, solved)191- }262+func verticalBridgeGlyph(count int) string {263+ if count == 2 {264+ return " ║ "192265 }193- // Also check if a bridge passes through this gap (non-adjacent islands)194- ci := m.puzzle.CellContent(lx, ly)195- if ci.Kind == cellBridgeH {196- return bridgeHView(ci.BridgeCount, solved)266+ return " ┃ "267+}268+269+func horizontalSeparatorBridge(m Model, x, y int) string {270+ count := horizontalBridgeCountAt(m.puzzle, x, y)271+ if count == 0 {272+ return ""197273 }198- ciR := m.puzzle.CellContent(lx+1, ly)199- if ciR.Kind == cellBridgeH {200- return bridgeHView(ciR.BridgeCount, solved)274+ if count == 2 {275+ return "═"201276 }202- return lipgloss.NewStyle().Width(cellWidth).Render(" ")277+ return "━"203278 }204279 205-// vGapView renders the vertical gap between logical cell (lx,ly) and (lx,ly+1).206-// Shows a bridge connector if both neighbors are islands connected by a bridge.207-func vGapView(m Model, lx, ly int, solved bool) string {208- top := m.puzzle.FindIslandAt(lx, ly)209- bottom := m.puzzle.FindIslandAt(lx, ly+1)210- if top != nil && bottom != nil {211- if b := m.puzzle.GetBridge(top.ID, bottom.ID); b != nil {212- return bridgeVView(b.Count, solved)213- }214- }215- // Also check if a bridge passes through this gap (non-adjacent islands)216- ci := m.puzzle.CellContent(lx, ly)217- if ci.Kind == cellBridgeV {218- return bridgeVView(ci.BridgeCount, solved)280+func verticalSeparatorBridge(m Model, x, y int) string {281+ count := verticalBridgeCountAt(m.puzzle, x, y)282+ if count == 0 {283+ return ""219284 }220- ciB := m.puzzle.CellContent(lx, ly+1)221- if ciB.Kind == cellBridgeV {222- return bridgeVView(ciB.BridgeCount, solved)285+ if count == 2 {286+ return " ║ "223287 }224- return lipgloss.NewStyle().Width(cellWidth).Render(" ")288+ return " ┃ "225289 }226290 227-func islandView(m Model, islandID int, solved bool) string {228- isl := m.puzzle.FindIslandByID(islandID)229- if isl == nil {230- return emptyDotStyle().Width(cellWidth).Render(" ")291+func horizontalBridgeCountAt(p Puzzle, x, y int) int {292+ if x <= 0 || x >= p.Width || y < 0 || y >= p.Height {293+ return 0231294 }232295 233- style := islandDefaultStyle(islandID)234- current := m.puzzle.BridgeCount(islandID)235- isCursor := m.cursorIsland == islandID236-237- if solved && isCursor {238- style = game.CursorSolvedStyle()239- } else if solved {240- colors := islandColors()241- bg := colors[islandID%len(colors)]242- style = lipgloss.NewStyle().243- Foreground(theme.TextOnBG(bg)).244- Background(bg).245- Bold(true)246- } else if m.selectedIsland != nil && *m.selectedIsland == islandID {247- style = islandSelectedStyle()248- } else if isCursor {249- style = game.CursorStyle()250- } else if current == isl.Required {251- style = islandSatisfiedStyle()252- } else if current > isl.Required {253- style = islandOverStyle()254- } else if isHighlightedNeighbor(m, islandID) {255- style = islandAdjacentStyle(islandID)296+ left := p.CellContent(x-1, y)297+ right := p.CellContent(x, y)298+299+ switch {300+ case left.Kind == cellBridgeH:301+ return left.BridgeCount302+ case right.Kind == cellBridgeH:303+ return right.BridgeCount304+ case left.Kind == cellIsland && right.Kind == cellIsland:305+ if b := p.GetBridge(left.IslandID, right.IslandID); b != nil {306+ return b.Count307+ }256308 }257309 258- label := fmt.Sprintf("%d", isl.Required)259- if isCursor && !solved {260- label = game.CursorLeft + fmt.Sprintf("%d", isl.Required) + game.CursorRight261- return style.Render(label)262- }263- return style.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(label)310+ return 0264311 }265312 266-func bridgeHView(count int, solved bool) string {267- r := "\u2500\u2500\u2500"268- if count == 2 {269- r = "\u2550\u2550\u2550"313+func verticalBridgeCountAt(p Puzzle, x, y int) int {314+ if y <= 0 || y >= p.Height || x < 0 || x >= p.Width {315+ return 0270316 }271- s := bridgeStyle()272- if solved {273- s = bridgeSolvedStyle()317+318+ top := p.CellContent(x, y-1)319+ bottom := p.CellContent(x, y)320+321+ switch {322+ case top.Kind == cellBridgeV:323+ return top.BridgeCount324+ case bottom.Kind == cellBridgeV:325+ return bottom.BridgeCount326+ case top.Kind == cellIsland && bottom.Kind == cellIsland:327+ if b := p.GetBridge(top.IslandID, bottom.IslandID); b != nil {328+ return b.Count329+ }274330 }275- return s.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(r)331+332+ return 0276333 }277334 278-func bridgeVView(count int, solved bool) string {279- r := "\u2502"280- if count == 2 {281- r = "\u2551"335+func bridgeForeground(m Model, bridge game.DynamicGridBridge) color.Color {336+ return bridgeColor(bridgeIndexForDynamicBridge(&m.puzzle, bridge))337+}338+339+func bridgeBold(m Model, bridge game.DynamicGridBridge) bool {340+ return bridgeCountForDynamicBridge(&m.puzzle, bridge) == 2341+}342+343+func bridgeIndexForDynamicBridge(p *Puzzle, bridge game.DynamicGridBridge) int {344+ count, index := dynamicBridgeIdentity(p, bridge)345+ _ = count346+ return index347+}348+349+func bridgeCountForDynamicBridge(p *Puzzle, bridge game.DynamicGridBridge) int {350+ count, _ := dynamicBridgeIdentity(p, bridge)351+ return count352+}353+354+func dynamicBridgeIdentity(p *Puzzle, bridge game.DynamicGridBridge) (count int, index int) {355+ for i := 0; i < bridge.Count; i++ {356+ cell := p.CellContent(bridge.Cells[i].X, bridge.Cells[i].Y)357+ switch cell.Kind {358+ case cellBridgeH, cellBridgeV:359+ return cell.BridgeCount, cell.BridgeIdx360+ }282361 }283- s := bridgeStyle()284- if solved {285- s = bridgeSolvedStyle()362+363+ ids := make([]int, 0, 2)364+ for i := 0; i < bridge.Count; i++ {365+ cell := p.CellContent(bridge.Cells[i].X, bridge.Cells[i].Y)366+ if cell.Kind != cellIsland {367+ continue368+ }369+ duplicate := false370+ for _, id := range ids {371+ if id == cell.IslandID {372+ duplicate = true373+ break374+ }375+ }376+ if !duplicate {377+ ids = append(ids, cell.IslandID)378+ }286379 }287- return s.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(r)380+381+ if len(ids) == 2 {382+ if p.bridgeIndex == nil {383+ p.buildBridgeIndex()384+ }385+ if idx, ok := p.bridgeIndex[bridgeKey(ids[0], ids[1])]; ok {386+ if idx >= 0 && idx < len(p.Bridges) {387+ return p.Bridges[idx].Count, idx388+ }389+ return 0, idx390+ }391+ }392+393+ return 0, -1288394 }289395 290396 func statusBarView(selected, showFullHelp bool) string {@@ -292,7 +398,7 @@ func statusBarView(selected, showFullHelp bool) string {
292398 if showFullHelp {293399 return game.StatusBarStyle().Render("arrows/wasd: build bridge enter/space: deselect esc: menu ctrl+r: reset ctrl+h: help")294400 }295- return game.StatusBarStyle().Render("arrows/wasd: build bridge enter/space: deselect")401+ return game.StatusBarStyle().Render("bridge mode arrows/wasd: build enter/space: deselect")296402 }297403 if showFullHelp {298404 return game.StatusBarStyle().Render("arrows/wasd: move enter/space: select island esc: menu ctrl+r: reset ctrl+h: help")@@ -306,6 +412,7 @@ func statusBarVariants() []string {
306412 statusBarView(false, true),307413 statusBarView(true, false),308414 statusBarView(true, true),415+ game.StatusBarStyle().Render("arrows/wasd: build bridge\nenter/space: deselect"),309416 }310417 }311418 @@ -211,6 +211,46 @@ func TestParseJSONLFileHydratesSudokuFromSave(t *testing.T) {
211211 }212212 }213213 214+func TestParseJSONLFileHydratesSudokuRGBFromSave(t *testing.T) {215+ path := filepath.Join(t.TempDir(), "sudoku-rgb.jsonl")216+ record := JSONLRecord{217+ Schema: ExportSchemaV1,218+ Pack: JSONLPackMeta{219+ Generated: "2026-02-22T10:00:00Z",220+ Version: "v-test",221+ Category: "Sudoku RGB",222+ ModeSelection: "Easy",223+ Count: 1,224+ },225+ Puzzle: JSONLPuzzle{226+ Index: 1,227+ Name: "ember-wave",228+ Game: "Sudoku RGB",229+ Mode: "Easy",230+ Save: json.RawMessage(`{"grid":"100000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000","provided":[{"x":0,"y":0,"v":1},{"x":1,"y":0,"v":4},{"x":2,"y":0,"v":3}]}`),231+ },232+ }233+ writeSingleJSONLRecord(t, path, record)234+235+ doc, err := ParseJSONLFile(path)236+ if err != nil {237+ t.Fatal(err)238+ }239+ if got, want := len(doc.Puzzles), 1; got != want {240+ t.Fatalf("puzzles = %d, want %d", got, want)241+ }242+ payload, ok := doc.Puzzles[0].PrintPayload.(*SudokuData)243+ if !ok || payload == nil {244+ t.Fatal("expected sudoku rgb print payload from save hydration")245+ }246+ if got, want := payload.Givens[0][0], 1; got != want {247+ t.Fatalf("sudoku rgb givens[0][0] = %d, want %d", got, want)248+ }249+ if got := payload.Givens[0][1]; got != 0 {250+ t.Fatalf("sudoku rgb givens[0][1] = %d, want 0", got)251+ }252+}253+214254 func TestParseJSONLFileHydratesWordSearchFromSave(t *testing.T) {215255 path := filepath.Join(t.TempDir(), "wordsearch.jsonl")216256 record := JSONLRecord{@@ -504,6 +544,9 @@ func ensureJSONLTestAdapters() {
504544 register("Sudoku", buildPayloadAdapter("Sudoku", func(save []byte) (any, error) {505545 return ParseSudokuPrintData(save)506546 }), "sudoku")547+ register("Sudoku RGB", buildPayloadAdapter("Sudoku RGB", func(save []byte) (any, error) {548+ return ParseSudokuRGBPrintData(save)549+ }), "sudoku rgb", "rgb sudoku", "ripeto", "sudoku ripeto")507550 register("Fillomino", buildPayloadAdapter("Fillomino", func(save []byte) (any, error) {508551 return ParseFillominoPrintData(save)509552 }), "fillomino")@@ -435,6 +435,30 @@ func ParseSudokuPrintData(saveData []byte) (*SudokuData, error) {
435435 return &SudokuData{Givens: givens}, nil436436 }437437 438+func ParseSudokuRGBPrintData(saveData []byte) (*SudokuData, error) {439+ if len(strings.TrimSpace(string(saveData))) == 0 {440+ return nil, nil441+ }442+443+ var save sudokuSave444+ if err := json.Unmarshal(saveData, &save); err != nil {445+ return nil, fmt.Errorf("decode sudoku rgb save: %w", err)446+ }447+448+ var givens [9][9]int449+ for _, cell := range save.Provided {450+ if !isSudokuCellInBounds(cell.X, cell.Y) {451+ continue452+ }453+ if cell.V < 1 || cell.V > 3 {454+ continue455+ }456+ givens[cell.Y][cell.X] = cell.V457+ }458+459+ return &SudokuData{Givens: givens}, nil460+}461+438462 func ParseFillominoPrintData(saveData []byte) (*FillominoData, error) {439463 if len(strings.TrimSpace(string(saveData))) == 0 {440464 return nil, nil@@ -209,6 +209,27 @@ func TestParseTakuzuPlusPrintData(t *testing.T) {
209209 }210210 }211211 212+func TestParseSudokuRGBPrintData(t *testing.T) {213+ save := []byte(`{"grid":"100000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000","provided":[{"x":0,"y":0,"v":1},{"x":1,"y":0,"v":4},{"x":2,"y":0,"v":3}]}`)214+215+ data, err := ParseSudokuRGBPrintData(save)216+ if err != nil {217+ t.Fatal(err)218+ }219+ if data == nil {220+ t.Fatal("expected sudoku rgb print data")221+ }222+ if got, want := data.Givens[0][0], 1; got != want {223+ t.Fatalf("givens[0][0] = %d, want %d", got, want)224+ }225+ if got := data.Givens[0][1]; got != 0 {226+ t.Fatalf("givens[0][1] = %d, want 0", got)227+ }228+ if got, want := data.Givens[0][2], 3; got != want {229+ t.Fatalf("givens[0][2] = %d, want %d", got, want)230+ }231+}232+212233 func TestParseFillominoPrintData(t *testing.T) {213234 save := []byte(`{"width":3,"height":2,"state":"1 . 2\n. 3 .","provided":"#.#\n.#."}`)214235 @@ -104,6 +104,24 @@ func TestMode(t *testing.T) {
104104 })105105 }106106 107+func TestModeSupportsSudokuRGBAliasCategory(t *testing.T) {108+ cat, err := catalog.Category("ripeto")109+ if err != nil {110+ t.Fatal(err)111+ }112+ if cat.Name != "Sudoku RGB" {113+ t.Fatalf("category name = %q, want %q", cat.Name, "Sudoku RGB")114+ }115+116+ _, title, err := Mode(cat, "easy")117+ if err != nil {118+ t.Fatal(err)119+ }120+ if title != "Easy" {121+ t.Fatalf("mode title = %q, want %q", title, "Easy")122+ }123+}124+107125 func TestModeNames(t *testing.T) {108126 cat, err := catalog.Category("Lights Out")109127 if err != nil {@@ -23,6 +23,11 @@ type Model struct {
2323 keys KeyMap2424 modeTitle string2525 showFullHelp bool26+ termWidth int27+ termHeight int28+ originX int29+ originY int30+ originValid bool2631 }2732 2833 func buildProvidedGrid(provided []cell) [gridSize][gridSize]bool {@@ -54,6 +59,18 @@ func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {
5459 switch msg := msg.(type) {5560 case game.HelpToggleMsg:5661 m.showFullHelp = msg.Show62+ m.originValid = false63+ case tea.WindowSizeMsg:64+ m.termWidth = msg.Width65+ m.termHeight = msg.Height66+ m.originValid = false67+ case tea.MouseClickMsg:68+ if msg.Button == tea.MouseLeft {69+ if col, row, ok := m.screenToGrid(msg.X, msg.Y); ok {70+ m.cursor.X = col71+ m.cursor.Y = row72+ }73+ }5774 case tea.KeyPressMsg:5875 switch {5976 case key.Matches(msg, m.keys.FillValue):@@ -81,6 +98,7 @@ func (m Model) IsSolved() bool {
8198 func (m Model) Reset() game.Gamer {8299 m.grid = newGrid(m.provided)83100 m.conflicts = computeConflicts(m.grid)101+ m.originValid = false84102 return m85103 }86104 @@ -88,14 +106,19 @@ func (m *Model) updateCell(v int) {
88106 if m.providedGrid[m.cursor.Y][m.cursor.X] {89107 return90108 }109+110+ wasSolved := m.isSolved()91111 m.grid[m.cursor.Y][m.cursor.X].v = v92112 m.conflicts = computeConflicts(m.grid)113+ if m.isSolved() != wasSolved {114+ m.originValid = false115+ }93116 }94117 95118 func (m Model) View() string {96119 solved := isSolvedWith(m.grid, m.conflicts)97120 title := game.TitleBarView("Sudoku", m.modeTitle, solved)98- grid := renderGrid(m, solved, m.conflicts)121+ grid := renderGrid(m, solved)99122 if solved {100123 return game.ComposeGameView(title, grid)101124 }@@ -0,0 +1,410 @@
1+package sudoku2+3+import (4+ "image"5+ "image/color"6+ "strings"7+ "testing"8+9+ tea "charm.land/bubbletea/v2"10+ "charm.land/lipgloss/v2"11+ "github.com/FelineStateMachine/puzzletea/game"12+ "github.com/FelineStateMachine/puzzletea/theme"13+ "github.com/charmbracelet/x/ansi"14+)15+16+func TestRenderGridUsesBoxBoundaries(t *testing.T) {17+ m := Model{18+ grid: emptyGrid(),19+ conflicts: computeConflicts(emptyGrid()),20+ keys: DefaultKeyMap,21+ }22+23+ lines := strings.Split(ansi.Strip(renderGrid(m, false)), "\n")24+ if got, want := len(lines), 19; got != want {25+ t.Fatalf("line count = %d, want %d", got, want)26+ }27+28+ contentRow := []rune(lines[1])29+ if got := contentRow[4]; got != ' ' {30+ t.Fatalf("interior bridge rune = %q, want %q", got, ' ')31+ }32+ if got := contentRow[12]; got != '│' {33+ t.Fatalf("box boundary rune = %q, want %q", got, '│')34+ }35+36+ innerBoundary := []rune(lines[2])37+ if got := innerBoundary[1]; got != ' ' {38+ t.Fatalf("interior row separator = %q, want %q", got, ' ')39+ }40+41+ boxBoundary := []rune(lines[6])42+ if got := boxBoundary[1]; got != '─' {43+ t.Fatalf("box row separator = %q, want %q", got, '─')44+ }45+}46+47+func TestBridgeFillUsesAxisSpillOnly(t *testing.T) {48+ m := Model{cursor: game.Cursor{X: 1, Y: 1}}49+ surface := theme.Current().Surface50+51+ tests := []struct {52+ name string53+ bridge game.DynamicGridBridge54+ want color.Color55+ }{56+ {57+ name: "vertical bridge spills left right on cursor row",58+ bridge: game.DynamicGridBridge{59+ Kind: game.DynamicGridBridgeVertical,60+ X: 3,61+ Y: 1,62+ Cells: [4]image.Point{63+ image.Pt(2, 1),64+ image.Pt(3, 1),65+ },66+ Count: 2,67+ },68+ want: surface,69+ },70+ {71+ name: "horizontal bridge spills up down on cursor column",72+ bridge: game.DynamicGridBridge{73+ Kind: game.DynamicGridBridgeHorizontal,74+ X: 1,75+ Y: 3,76+ Cells: [4]image.Point{77+ image.Pt(1, 2),78+ image.Pt(1, 3),79+ },80+ Count: 2,81+ },82+ want: surface,83+ },84+ {85+ name: "off axis bridge stays empty",86+ bridge: game.DynamicGridBridge{87+ Kind: game.DynamicGridBridgeVertical,88+ X: 3,89+ Y: 4,90+ Cells: [4]image.Point{91+ image.Pt(2, 4),92+ image.Pt(3, 4),93+ },94+ Count: 2,95+ },96+ want: nil,97+ },98+ {99+ name: "junction stays empty",100+ bridge: game.DynamicGridBridge{101+ Kind: game.DynamicGridBridgeJunction,102+ X: 2,103+ Y: 1,104+ Cells: [4]image.Point{image.Pt(1, 0), image.Pt(1, 1), image.Pt(2, 0), image.Pt(2, 1)},105+ Count: 4,106+ Zone: 0,107+ Uniform: true,108+ },109+ want: nil,110+ },111+ }112+113+ for _, tt := range tests {114+ t.Run(tt.name, func(t *testing.T) {115+ if got := bridgeFill(m, false, tt.bridge); !sameColor(got, tt.want) {116+ t.Fatalf("bridgeFill() = %v, want %v", got, tt.want)117+ }118+ })119+ }120+}121+122+func TestZoneFillHighlightsOnlyActiveBox(t *testing.T) {123+ m := Model{cursor: game.Cursor{X: 1, Y: 1}}124+ surface := theme.Current().Surface125+126+ inBox := game.DynamicGridBridge{127+ Kind: game.DynamicGridBridgeVertical,128+ X: 2,129+ Y: 1,130+ Cells: [4]image.Point{image.Pt(1, 1), image.Pt(2, 1)},131+ Count: 2,132+ Zone: 0,133+ Uniform: true,134+ }135+ if got := activeBoxZoneFill(m.cursor, false, inBox.Zone); !sameColor(got, surface) {136+ t.Fatalf("in-box bridge background = %v, want %v", got, surface)137+ }138+139+ inBoxJunction := game.DynamicGridBridge{140+ Kind: game.DynamicGridBridgeJunction,141+ X: 2,142+ Y: 2,143+ Cells: [4]image.Point{image.Pt(1, 1), image.Pt(2, 1), image.Pt(1, 2), image.Pt(2, 2)},144+ Count: 4,145+ Zone: 0,146+ Uniform: true,147+ }148+ if got := activeBoxZoneFill(m.cursor, false, inBoxJunction.Zone); !sameColor(got, surface) {149+ t.Fatalf("in-box junction background = %v, want %v", got, surface)150+ }151+152+ outOfBox := game.DynamicGridBridge{153+ Kind: game.DynamicGridBridgeVertical,154+ X: 4,155+ Y: 1,156+ Cells: [4]image.Point{image.Pt(3, 1), image.Pt(4, 1)},157+ Count: 2,158+ Zone: 1,159+ Uniform: true,160+ }161+ if got := activeBoxZoneFill(m.cursor, false, outOfBox.Zone); got != nil {162+ t.Fatalf("out-of-box bridge background = %v, want nil", got)163+ }164+165+ if got := activeBoxZoneFill(m.cursor, true, inBox.Zone); got != nil {166+ t.Fatalf("solved in-box bridge background = %v, want nil", got)167+ }168+}169+170+func TestCellViewSameNumberOverridesProvidedCrosshair(t *testing.T) {171+ g := validCompleteGrid()172+ m := Model{173+ cursor: game.Cursor{X: 0, Y: 0},174+ grid: g,175+ keys: DefaultKeyMap,176+ }177+ m.providedGrid[4][4] = true178+179+ got := cellView(m, 4, 4, false)180+ want := sameNumberStyle(5).Width(cellWidth).AlignHorizontal(lipgloss.Center).Render("5")181+ if got != want {182+ t.Fatalf("cellView() = %q, want %q", got, want)183+ }184+}185+186+func TestCellViewUsesSemanticDigitColors(t *testing.T) {187+ p := theme.Current()188+ g := emptyGrid()189+ g[0][0].v = 5190+ g[0][1].v = 7191+ m := Model{192+ cursor: game.Cursor{X: 8, Y: 8},193+ grid: g,194+ keys: DefaultKeyMap,195+ }196+ m.providedGrid[0][1] = true197+198+ gotUser := cellView(m, 0, 0, false)199+ wantUser := lipgloss.NewStyle().200+ Foreground(digitColor(5)).201+ Background(p.BG).202+ Width(cellWidth).203+ AlignHorizontal(lipgloss.Center).204+ Render("5")205+ if gotUser != wantUser {206+ t.Fatalf("user cellView() = %q, want %q", gotUser, wantUser)207+ }208+209+ gotProvided := cellView(m, 1, 0, false)210+ wantProvided := lipgloss.NewStyle().211+ Bold(true).212+ Foreground(digitColor(7)).213+ Background(p.BG).214+ Width(cellWidth).215+ AlignHorizontal(lipgloss.Center).216+ Render("7")217+ if gotProvided != wantProvided {218+ t.Fatalf("provided cellView() = %q, want %q", gotProvided, wantProvided)219+ }220+}221+222+func TestCellViewUsesDigitColorForFilledCursor(t *testing.T) {223+ g := emptyGrid()224+ g[1][1].v = 6225+ m := Model{226+ cursor: game.Cursor{X: 1, Y: 1},227+ grid: g,228+ keys: DefaultKeyMap,229+ }230+231+ got := cellView(m, 1, 1, false)232+ want := digitCursorStyle(6).233+ Width(cellWidth).234+ AlignHorizontal(lipgloss.Center).235+ Render(game.CursorLeft + "6" + game.CursorRight)236+ if got != want {237+ t.Fatalf("filled cursor cellView() = %q, want %q", got, want)238+ }239+}240+241+func TestCellViewKeepsDefaultCursorStyleForEmptyCell(t *testing.T) {242+ m := Model{243+ cursor: game.Cursor{X: 1, Y: 1},244+ grid: emptyGrid(),245+ keys: DefaultKeyMap,246+ }247+248+ got := cellView(m, 1, 1, false)249+ want := game.CursorStyle().250+ Width(cellWidth).251+ AlignHorizontal(lipgloss.Center).252+ Render(game.CursorLeft + "·" + game.CursorRight)253+ if got != want {254+ t.Fatalf("empty cursor cellView() = %q, want %q", got, want)255+ }256+}257+258+func TestScreenToGrid(t *testing.T) {259+ m := testDynamicBoardModel()260+ ox, oy := m.gridOrigin()261+262+ tests := []struct {263+ name string264+ screenX int265+ screenY int266+ wantCol int267+ wantRow int268+ wantOK bool269+ }{270+ {"origin cell", ox, oy, 0, 0, true},271+ {"next cell", ox + cellWidth + 1, oy, 1, 0, true},272+ {"next row", ox, oy + 2, 0, 1, true},273+ {"vertical separator", ox + cellWidth, oy, 0, 0, false},274+ {"horizontal separator", ox, oy + 1, 0, 0, false},275+ {"outside left", ox - 1, oy, 0, 0, false},276+ {"outside bottom", ox, oy + 18, 0, 0, false},277+ }278+279+ for _, tt := range tests {280+ t.Run(tt.name, func(t *testing.T) {281+ col, row, ok := m.screenToGrid(tt.screenX, tt.screenY)282+ if ok != tt.wantOK {283+ t.Fatalf("ok = %v, want %v", ok, tt.wantOK)284+ }285+ if ok && (col != tt.wantCol || row != tt.wantRow) {286+ t.Fatalf("screenToGrid(%d, %d) = (%d, %d), want (%d, %d)", tt.screenX, tt.screenY, col, row, tt.wantCol, tt.wantRow)287+ }288+ })289+ }290+}291+292+func TestMouseClickMovesCursor(t *testing.T) {293+ m := testDynamicBoardModel()294+295+ x, y := sudokuCellScreenCoords(&m, 2, 1)296+ next, _ := m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})297+ got := next.(Model)298+299+ if got.cursor.X != 2 || got.cursor.Y != 1 {300+ t.Fatalf("cursor = (%d,%d), want (2,1)", got.cursor.X, got.cursor.Y)301+ }302+}303+304+func TestMouseClickOnSeparatorDoesNotMoveCursor(t *testing.T) {305+ m := testDynamicBoardModel()306+ m.cursor = game.Cursor{X: 1, Y: 1}307+308+ x, y := sudokuCellScreenCoords(&m, 1, 1)309+ next, _ := m.Update(tea.MouseClickMsg{X: x + cellWidth, Y: y, Button: tea.MouseLeft})310+ got := next.(Model)311+312+ if got.cursor.X != 1 || got.cursor.Y != 1 {313+ t.Fatalf("cursor = (%d,%d), want unchanged (1,1)", got.cursor.X, got.cursor.Y)314+ }315+}316+317+func TestMouseClickSameCellDoesNotMutateValue(t *testing.T) {318+ m := testDynamicBoardModel()319+ m.cursor = game.Cursor{X: 1, Y: 1}320+ m.grid[1][1].v = 4321+322+ x, y := sudokuCellScreenCoords(&m, 1, 1)323+ next, _ := m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})324+ got := next.(Model)325+326+ if got.grid[1][1].v != 4 {327+ t.Fatalf("cell value = %d, want unchanged 4", got.grid[1][1].v)328+ }329+}330+331+func TestOriginInvalidation(t *testing.T) {332+ t.Run("help toggle", func(t *testing.T) {333+ m := testDynamicBoardModel()334+ m.originValid = true335+336+ next, _ := m.Update(game.HelpToggleMsg{Show: true})337+ got := next.(Model)338+ if got.originValid {339+ t.Fatal("expected help toggle to invalidate origin cache")340+ }341+ })342+343+ t.Run("window size", func(t *testing.T) {344+ m := testDynamicBoardModel()345+ m.originValid = true346+347+ next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})348+ got := next.(Model)349+ if got.originValid {350+ t.Fatal("expected window resize to invalidate origin cache")351+ }352+ })353+354+ t.Run("reset", func(t *testing.T) {355+ m := testDynamicBoardModel()356+ m.originValid = true357+358+ got := m.Reset().(Model)359+ if got.originValid {360+ t.Fatal("expected reset to invalidate origin cache")361+ }362+ })363+364+ t.Run("solved transition", func(t *testing.T) {365+ g := validCompleteGrid()366+ g[0][0].v = 0367+368+ m := Model{369+ grid: g,370+ conflicts: computeConflicts(g),371+ keys: DefaultKeyMap,372+ originValid: true,373+ }374+375+ m.updateCell(5)376+ if m.originValid {377+ t.Fatal("expected solving move to invalidate origin cache")378+ }379+ if !m.isSolved() {380+ t.Fatal("expected board to be solved after filling final cell")381+ }382+ })383+}384+385+func testDynamicBoardModel() Model {386+ g := emptyGrid()387+ return Model{388+ grid: g,389+ conflicts: computeConflicts(g),390+ keys: DefaultKeyMap,391+ modeTitle: "Test",392+ termWidth: 120,393+ termHeight: 40,394+ }395+}396+397+func sudokuCellScreenCoords(m *Model, col, row int) (int, int) {398+ ox, oy := m.gridOrigin()399+ return ox + col*(cellWidth+1), oy + row*2400+}401+402+func sameColor(a, b color.Color) bool {403+ if a == nil || b == nil {404+ return a == b405+ }406+407+ ar, ag, ab, aa := a.RGBA()408+ br, bg, bb, ba := b.RGBA()409+ return ar == br && ag == bg && ab == bb && aa == ba410+}@@ -15,6 +15,7 @@ Fill a 9x9 grid with digits so every row, column, and box is complete.
1515 | Key | Action |1616 |-----|--------|1717 | `Arrows` / `wasd` / `hjkl` | Move cursor |18+| `Mouse left-click` | Focus a cell |1819 | `1`-`9` | Fill cell with digit |1920 | `Backspace` | Clear cell |2021 | `Ctrl+H` | Toggle help bar |@@ -0,0 +1,33 @@
1+package sudoku2+3+import "github.com/FelineStateMachine/puzzletea/game"4+5+func (m *Model) screenToGrid(screenX, screenY int) (col, row int, ok bool) {6+ ox, oy := m.cachedGridOrigin()7+ return game.DynamicGridScreenToCell(8+ game.DynamicGridMetrics{Width: gridSize, Height: gridSize, CellWidth: cellWidth},9+ ox,10+ oy,11+ screenX,12+ screenY,13+ false,14+ )15+}16+17+func (m *Model) cachedGridOrigin() (x, y int) {18+ if m.originValid {19+ return m.originX, m.originY20+ }21+22+ x, y = m.gridOrigin()23+ m.originX, m.originY = x, y24+ m.originValid = true25+ return x, y26+}27+28+func (m *Model) gridOrigin() (x, y int) {29+ solved := m.isSolved()30+ title := game.TitleBarView("Sudoku", m.modeTitle, solved)31+ grid := renderGrid(*m, solved)32+ return game.DynamicGridOrigin(m.termWidth, m.termHeight, m.View(), title, grid)33+}@@ -1,15 +1,15 @@
11 package sudoku22 33 import (4- "fmt"5- "strings"4+ "image/color"5+ "strconv"66 77 "charm.land/lipgloss/v2"88 "github.com/FelineStateMachine/puzzletea/game"99 "github.com/FelineStateMachine/puzzletea/theme"1010 )1111 12-var sudokuCellWidth = 212+const cellWidth = game.DynamicGridCellWidth1313 1414 func emptyCellStyle() lipgloss.Style {1515 p := theme.Current()@@ -18,18 +18,18 @@ func emptyCellStyle() lipgloss.Style {
1818 Background(p.BG)1919 }2020 21-func providedCellStyle() lipgloss.Style {21+func providedCellStyle(v int) lipgloss.Style {2222 p := theme.Current()2323 return lipgloss.NewStyle().2424 Bold(true).25- Foreground(p.Given).25+ Foreground(digitColor(v)).2626 Background(p.BG)2727 }2828 29-func userCellStyle() lipgloss.Style {29+func userCellStyle(v int) lipgloss.Style {3030 p := theme.Current()3131 return lipgloss.NewStyle().32- Foreground(p.AccentSoft).32+ Foreground(digitColor(v)).3333 Background(p.BG)3434 }3535 @@ -40,150 +40,176 @@ func conflictCellStyle() lipgloss.Style {
4040 Background(p.ErrorBG)4141 }4242 43-func sameNumberStyle() lipgloss.Style {44- p := theme.Current()43+func sameNumberStyle(v int) lipgloss.Style {4544 return lipgloss.NewStyle().4645 Bold(true).47- Foreground(p.Accent)46+ Foreground(digitColor(v))4847 }4948 50-func gridBorderStyle() lipgloss.Style {51- p := theme.Current()49+func digitCursorStyle(value int) lipgloss.Style {50+ bg := digitColor(value)5251 return lipgloss.NewStyle().53- Border(lipgloss.RoundedBorder()).54- BorderForeground(p.Border).55- BorderBackground(p.BG)52+ Bold(true).53+ Foreground(theme.TextOnBG(bg)).54+ Background(bg)5655 }5756 58-func gridBorderSolvedStyle() lipgloss.Style {59- p := theme.Current()60- return lipgloss.NewStyle().61- Border(lipgloss.RoundedBorder()).62- BorderForeground(p.SuccessBorder).63- BorderBackground(p.SuccessBG)57+func renderGrid(m Model, solved bool) string {58+ return game.RenderDynamicGrid(game.DynamicGridSpec{59+ Width: gridSize,60+ Height: gridSize,61+ Solved: solved,62+ Cell: func(x, y int) string {63+ return cellView(m, x, y, solved)64+ },65+ ZoneAt: func(x, y int) int {66+ return sudokuBoxIndex(x, y)67+ },68+ ZoneFill: func(zone int) color.Color {69+ return activeBoxZoneFill(m.cursor, solved, zone)70+ },71+ BridgeFill: func(bridge game.DynamicGridBridge) color.Color {72+ return bridgeFill(m, solved, bridge)73+ },74+ })6475 }6576 66-func renderGrid(m Model, solved bool, conflicts [gridSize][gridSize]bool) string {67- p := theme.Current()68-69- var rows []string77+func cellView(m Model, x, y int, solved bool) string {78+ c := m.grid[y][x]79+ style := cellStyle(m, c, x, y, m.conflicts[y][x], solved)80+ text := cellContent(c)7081 71- for y := range gridSize {72- var cells []string73- for x := range gridSize {74- c := m.grid[y][x]75- style := getCellStyle(m, c, x, y, conflicts[y][x], solved)76- content := cellContent(c)77- rendered := style.Width(sudokuCellWidth).Align(lipgloss.Center).Render(content)78- cells = append(cells, rendered)79-80- if x == 2 || x == 5 {81- bg := p.BG82- if m.cursor.Y == y {83- bg = p.Surface84- }85- sep := lipgloss.NewStyle().Foreground(p.Border).Background(bg).Render("\u2502")86- cells = append(cells, sep)87- }88- }89- row := lipgloss.JoinHorizontal(lipgloss.Top, cells...)90- rows = append(rows, row)91-92- if y == 2 || y == 5 {93- sepLine := strings.Repeat("\u2500", sudokuCellWidth)94- var renderedParts []string95- for x := range gridSize {96- bg := p.BG97- fg := p.Border98- if solved {99- bg = p.SuccessBG100- fg = p.SuccessBorder101- } else if m.cursor.X == x {102- bg = p.Surface103- }104- renderedParts = append(renderedParts, lipgloss.NewStyle().Foreground(fg).Background(bg).Render(sepLine))105- if x == 2 || x == 5 {106- crossBG := p.BG107- crossFG := p.Border108- if solved {109- crossBG = p.SuccessBG110- crossFG = p.SuccessBorder111- }112- renderedParts = append(renderedParts, lipgloss.NewStyle().Foreground(crossFG).Background(crossBG).Render("\u253c"))113- }114- }115- rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, renderedParts...))82+ if x == m.cursor.X && y == m.cursor.Y {83+ if c.v == 0 {84+ text = game.CursorLeft + "·" + game.CursorRight85+ } else {86+ text = game.CursorLeft + strconv.Itoa(c.v) + game.CursorRight11687 }11788 }11889 119- grid := lipgloss.JoinVertical(lipgloss.Left, rows...)120-121- if solved {122- return gridBorderSolvedStyle().Render(grid)123- }124- return gridBorderStyle().Render(grid)90+ return style.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(text)12591 }12692 127-func getCellStyle(m Model, c cell, x, y int, conflict, solved bool) lipgloss.Style {93+func cellStyle(m Model, c cell, x, y int, conflict, solved bool) lipgloss.Style {12894 p := theme.Current()12995 isCursor := m.cursor.X == x && m.cursor.Y == y13096 cursorVal := m.grid[m.cursor.Y][m.cursor.X].v13197 132- // Priority: cursor+solved > cursor > conflict > same number > provided > user > empty133- if isCursor && solved {98+ switch {99+ case isCursor && solved:100+ if c.v != 0 {101+ return digitCursorStyle(c.v)102+ }134103 return game.CursorSolvedStyle()104+ case isCursor:105+ if c.v != 0 {106+ return digitCursorStyle(c.v)107+ }108+ return game.CursorStyle()109+ case solved:110+ return lipgloss.NewStyle().111+ Foreground(p.SolvedFG).112+ Background(p.SuccessBG)113+ case conflict:114+ return conflictCellStyle()115+ case cursorVal != 0 && c.v == cursorVal:116+ return sameNumberStyle(c.v)135117 }136118 137- if isCursor {138- return game.CursorStyle()119+ isProvided := m.providedGrid[y][x]120+ if isProvided {121+ style := providedCellStyle(c.v)122+ if inCursorContext(m.cursor, x, y) {123+ style = style.Background(p.Surface)124+ }125+ return style139126 }140127 141- if solved {142- return lipgloss.NewStyle().Foreground(p.SolvedFG).Background(p.SuccessBG)128+ if c.v != 0 {129+ style := userCellStyle(c.v)130+ if inCursorContext(m.cursor, x, y) {131+ style = style.Background(p.Surface)132+ }133+ return style143134 }144135 145- if conflict {146- return conflictCellStyle()136+ style := emptyCellStyle()137+ if inCursorContext(m.cursor, x, y) {138+ style = style.Background(p.Surface)147139 }140+ return style141+}148142 149- if cursorVal != 0 && c.v == cursorVal {150- return sameNumberStyle()143+func bridgeFill(m Model, solved bool, bridge game.DynamicGridBridge) color.Color {144+ if solved {145+ return nil151146 }152147 153- isProvided := m.providedGrid[y][x]154- inCursorRow := m.cursor.Y == y155- inCursorCol := m.cursor.X == x156- inCursorBox := (x/3 == m.cursor.X/3) && (y/3 == m.cursor.Y/3)157- inCrosshair := inCursorRow || inCursorCol || inCursorBox148+ if bridgeOnCrosshairAxis(m.cursor, bridge) {149+ return theme.Current().Surface150+ }151+ return nil152+}158153 159- if isProvided {160- s := providedCellStyle()161- if inCrosshair {162- s = s.Background(p.Surface)163- }164- return s154+func activeBoxZoneFill(cursor game.Cursor, solved bool, zone int) color.Color {155+ if solved || zone != sudokuBoxIndex(cursor.X, cursor.Y) {156+ return nil165157 }158+ return theme.Current().Surface159+}166160 167- if c.v != 0 {168- s := userCellStyle()169- if inCrosshair {170- s = s.Background(p.Surface)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+ }171180 }172- return s173181 }182+ return false183+}174184 175- s := emptyCellStyle()176- if inCrosshair {177- s = s.Background(p.Surface)185+func inCursorContext(cursor game.Cursor, x, y int) bool {186+ return cursor.X == x ||187+ cursor.Y == y ||188+ sudokuBoxIndex(cursor.X, cursor.Y) == sudokuBoxIndex(x, y)189+}190+191+func sudokuBoxIndex(x, y int) int {192+ return (y / 3 * 3) + (x / 3)193+}194+195+func digitColor(value int) color.Color {196+ if value <= 0 {197+ return theme.Current().TextDim178198 }179- return s199+200+ colors := theme.Current().ThemeColors()201+ if len(colors) == 0 {202+ return theme.Current().FG203+ }204+205+ return colors[(value-1)%len(colors)]180206 }181207 182208 func cellContent(c cell) string {183209 if c.v == 0 {184- return "\u00b7"210+ return "·"185211 }186- return fmt.Sprintf("%d", c.v)212+ return strconv.Itoa(c.v)187213 }188214 189215 // computeConflicts returns a grid of booleans indicating which cells have conflicts.@@ -253,7 +279,7 @@ func computeConflicts(g grid) [gridSize][gridSize]bool {
253279 254280 func statusBarView(showFullHelp bool) string {255281 if showFullHelp {256- return game.StatusBarStyle().Render("arrows/wasd: move 1-9: fill bkspc: clear esc: menu ctrl+r: reset ctrl+h: help")282+ return game.StatusBarStyle().Render("mouse: click focus arrows/wasd: move 1-9: fill bkspc: clear esc: menu ctrl+r: reset ctrl+h: help")257283 }258- return game.StatusBarStyle().Render("1-9: fill bkspc: clear")284+ return game.StatusBarStyle().Render("mouse: click focus 1-9: fill bkspc: clear")259285 }@@ -0,0 +1,81 @@
1+package sudokurgb2+3+import (4+ "encoding/json"5+ "fmt"6+ "strings"7+)8+9+type exportCell struct {10+ X int `json:"x"`11+ Y int `json:"y"`12+ V int `json:"v"`13+}14+15+type Save struct {16+ Grid string `json:"grid"`17+ Provided []exportCell `json:"provided"`18+ ModeTitle string `json:"mode_title,omitempty"`19+}20+21+func gridToString(g grid) string {22+ var b strings.Builder23+ for y := range gridSize {24+ for x := range gridSize {25+ b.WriteByte(byte('0' + g[y][x].v))26+ }27+ if y < gridSize-1 {28+ b.WriteByte('\n')29+ }30+ }31+ return b.String()32+}33+34+func ImportModel(data []byte) (*Model, error) {35+ var save Save36+ if err := json.Unmarshal(data, &save); err != nil {37+ return nil, err38+ }39+40+ provided := make([]cell, len(save.Provided))41+ for i, ec := range save.Provided {42+ provided[i] = cell{x: ec.X, y: ec.Y, v: ec.V}43+ }44+45+ g := newGrid(provided)46+ rows := strings.Split(save.Grid, "\n")47+ for y := 0; y < gridSize && y < len(rows); y++ {48+ for x := 0; x < gridSize && x < len(rows[y]); x++ {49+ value := int(rows[y][x] - '0')50+ if value >= 1 && value <= valueCount {51+ g[y][x].v = value52+ }53+ }54+ }55+56+ return &Model{57+ grid: g,58+ provided: provided,59+ providedGrid: buildProvidedGrid(provided),60+ conflicts: computeConflicts(g),61+ modeTitle: save.ModeTitle,62+ keys: DefaultKeyMap,63+ }, nil64+}65+66+func (m Model) GetSave() ([]byte, error) {67+ provided := make([]exportCell, len(m.provided))68+ for i, c := range m.provided {69+ provided[i] = exportCell{X: c.x, Y: c.y, V: c.v}70+ }71+ save := Save{72+ Grid: gridToString(m.grid),73+ Provided: provided,74+ ModeTitle: m.modeTitle,75+ }76+ jsonData, err := json.Marshal(save)77+ if err != nil {78+ return nil, fmt.Errorf("unable to marshal save data: %w", err)79+ }80+ return jsonData, nil81+}@@ -0,0 +1,62 @@
1+package sudokurgb2+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 SudokuRGBMode struct {15+ game.BaseMode16+ ProvidedCount int17+}18+19+var (20+ _ game.Mode = SudokuRGBMode{}21+ _ game.Spawner = SudokuRGBMode{}22+ _ game.SeededSpawner = SudokuRGBMode{}23+)24+25+func NewMode(title, description string, providedCount int) SudokuRGBMode {26+ return SudokuRGBMode{27+ BaseMode: game.NewBaseMode(title, description),28+ ProvidedCount: providedCount,29+ }30+}31+32+func (s SudokuRGBMode) Spawn() (game.Gamer, error) {33+ return New(s, GenerateProvidedCells(s))34+}35+36+func (s SudokuRGBMode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) {37+ return New(s, GenerateProvidedCellsSeeded(s, rng))38+}39+40+var Modes = []list.Item{41+ NewMode("Beginner", "60 clues. Gentle intro to RGB quota logic.", 60),42+ NewMode("Easy", "54 clues. Early rows and boxes resolve quickly.", 54),43+ NewMode("Medium", "48 clues. Mixed row, column, and box pressure.", 48),44+ NewMode("Hard", "42 clues. More ambiguous houses and cross-checking.", 42),45+ NewMode("Expert", "36 clues. Sparse givens require deeper scanning.", 36),46+ NewMode("Diabolical", "30 clues. Tightest clue budget in the launch set.", 30),47+}48+49+var DailyModes = []list.Item{50+ Modes[1], // Easy51+ Modes[2], // Medium52+}53+54+var Definition = game.Definition{55+ Name: "Sudoku RGB",56+ Description: "Fill the board with RGB symbols so each row, column, and 3x3 box contains {1,1,1,2,2,2,3,3,3}. [1,2,3] maps to [▲,■,●]. Inspired by Sudoku Ripeto.",57+ Aliases: []string{"rgb sudoku", "ripeto", "sudoku ripeto"},58+ Modes: Modes,59+ DailyModes: DailyModes,60+ Help: HelpContent,61+ Import: func(data []byte) (game.Gamer, error) { return ImportModel(data) },62+}@@ -0,0 +1,175 @@
1+package sudokurgb2+3+import (4+ "fmt"5+ "strconv"6+7+ "charm.land/bubbles/v2/key"8+ tea "charm.land/bubbletea/v2"9+ "github.com/FelineStateMachine/puzzletea/game"10+)11+12+const (13+ gridSize = 914+ valueCount = 315+ houseQuota = 316+)17+18+var _ game.Gamer = Model{}19+20+type Model struct {21+ cursor game.Cursor22+ grid grid23+ provided []cell24+ providedGrid [gridSize][gridSize]bool25+ conflicts [gridSize][gridSize]bool26+ keys KeyMap27+ modeTitle string28+ showFullHelp bool29+ termWidth int30+ termHeight int31+ originX int32+ originY int33+ originValid bool34+}35+36+func buildProvidedGrid(provided []cell) [gridSize][gridSize]bool {37+ var pg [gridSize][gridSize]bool38+ for _, c := range provided {39+ pg[c.y][c.x] = true40+ }41+ return pg42+}43+44+func New(mode SudokuRGBMode, provided []cell) (game.Gamer, error) {45+ g := newGrid(provided)46+ m := Model{47+ grid: g,48+ provided: provided,49+ providedGrid: buildProvidedGrid(provided),50+ conflicts: computeConflicts(g),51+ keys: DefaultKeyMap,52+ modeTitle: mode.Title(),53+ }54+ return m, nil55+}56+57+func (m Model) Init() tea.Cmd {58+ return nil59+}60+61+func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {62+ switch msg := msg.(type) {63+ case game.HelpToggleMsg:64+ m.showFullHelp = msg.Show65+ m.originValid = false66+ case tea.WindowSizeMsg:67+ m.termWidth = msg.Width68+ m.termHeight = msg.Height69+ m.originValid = false70+ case tea.MouseClickMsg:71+ if msg.Button == tea.MouseLeft {72+ if col, row, ok := m.screenToGrid(msg.X, msg.Y); ok {73+ m.cursor.X = col74+ m.cursor.Y = row75+ }76+ }77+ case tea.KeyPressMsg:78+ switch {79+ case key.Matches(msg, m.keys.FillValue):80+ value, _ := strconv.Atoi(msg.String())81+ m.updateCell(value)82+ case key.Matches(msg, m.keys.ClearCell):83+ m.updateCell(0)84+ default:85+ m.cursor.Move(m.keys.CursorKeyMap, msg, gridSize-1, gridSize-1)86+ }87+ }88+89+ m.updateKeyBindings()90+ return m, nil91+}92+93+func (m Model) SetTitle(t string) game.Gamer {94+ m.modeTitle = t95+ return m96+}97+98+func (m Model) IsSolved() bool {99+ return m.isSolved()100+}101+102+func (m Model) Reset() game.Gamer {103+ m.grid = newGrid(m.provided)104+ m.conflicts = computeConflicts(m.grid)105+ m.originValid = false106+ return m107+}108+109+func (m *Model) updateCell(value int) {110+ if m.providedGrid[m.cursor.Y][m.cursor.X] {111+ return112+ }113+114+ wasSolved := m.isSolved()115+ m.grid[m.cursor.Y][m.cursor.X].v = value116+ m.conflicts = computeConflicts(m.grid)117+ if m.isSolved() != wasSolved {118+ m.originValid = false119+ }120+}121+122+func (m Model) View() string {123+ solved := isSolvedWith(m.grid, m.conflicts)124+ title := game.TitleBarView("Sudoku RGB", m.modeTitle, solved)125+ grid := renderGrid(m, solved)126+ if solved {127+ return game.ComposeGameView(title, grid)128+ }129+130+ status := statusBarView(m.showFullHelp)131+ return game.ComposeGameViewRows(title, grid, game.StableRow(status, statusBarView(false), statusBarView(true)))132+}133+134+func (m Model) GetDebugInfo() string {135+ cursorCell := m.grid[m.cursor.Y][m.cursor.X]136+ isProvided := m.providedGrid[m.cursor.Y][m.cursor.X]137+ conflict := m.conflicts[m.cursor.Y][m.cursor.X]138+ solved := m.isSolved()139+140+ filledCount := 0141+ conflictCount := 0142+ for y := range gridSize {143+ for x := range gridSize {144+ if m.grid[y][x].v != 0 {145+ filledCount++146+ }147+ if m.conflicts[y][x] {148+ conflictCount++149+ }150+ }151+ }152+153+ status := "In Progress"154+ if solved {155+ status = "Solved"156+ }157+158+ return game.DebugHeader("Sudoku RGB", [][2]string{159+ {"Status", status},160+ {"Cursor", fmt.Sprintf("(%d, %d)", m.cursor.X, m.cursor.Y)},161+ {"Cell Value", cellDebugValue(cursorCell.v)},162+ {"Is Provided", fmt.Sprintf("%v", isProvided)},163+ {"Has Conflict", fmt.Sprintf("%v", conflict)},164+ {"Cells Filled", fmt.Sprintf("%d / 81", filledCount)},165+ {"Conflict Count", fmt.Sprintf("%d", conflictCount)},166+ {"Provided Count", fmt.Sprintf("%d", len(m.provided))},167+ })168+}169+170+func cellDebugValue(value int) string {171+ if value == 0 {172+ return "empty"173+ }174+ return fmt.Sprintf("%d (%s)", value, cellContentValue(value))175+}@@ -0,0 +1,114 @@
1+package sudokurgb2+3+import (4+ "strconv"5+6+ "codeberg.org/go-pdf/fpdf"7+ "github.com/FelineStateMachine/puzzletea/game"8+ "github.com/FelineStateMachine/puzzletea/pdfexport"9+)10+11+type printAdapter struct{}12+13+func (printAdapter) CanonicalGameType() string { return "Sudoku RGB" }14+func (printAdapter) Aliases() []string {15+ return []string{"sudoku rgb", "rgb sudoku", "ripeto", "sudoku ripeto"}16+}17+18+func (printAdapter) BuildPDFPayload(save []byte) (any, error) {19+ return pdfexport.ParseSudokuRGBPrintData(save)20+}21+22+func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error {23+ switch data := payload.(type) {24+ case *pdfexport.SudokuData:25+ renderSudokuRGBPage(pdf, data)26+ }27+ return nil28+}29+30+func renderSudokuRGBPage(pdf *fpdf.Fpdf, data *pdfexport.SudokuData) {31+ if data == nil {32+ return33+ }34+35+ pageW, pageH := pdf.GetPageSize()36+ pageNo := pdf.PageNo()37+ area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, 1)38+ cellSize := pdfexport.FitSudokuCellSize(9, 9, area)39+ if cellSize <= 0 {40+ return41+ }42+43+ boardH := 9.0 * cellSize44+ startX, startY := pdfexport.CenteredOrigin(area, 9, 9, cellSize)45+46+ drawSudokuGridLines(pdf, startX, startY, cellSize)47+ drawSudokuRGBGivens(pdf, startX, startY, cellSize, data.Givens)48+49+ ruleY := pdfexport.InstructionY(startY+boardH, pageH, 1)50+ pdfexport.SetInstructionStyle(pdf)51+ pdf.SetXY(area.X, ruleY)52+ pdf.CellFormat(53+ area.W,54+ pdfexport.InstructionLineHMM,55+ "Fill rows, columns, and 3x3 boxes with three 1s, three 2s, and three 3s",56+ "",57+ 0,58+ "C",59+ false,60+ 0,61+ "",62+ )63+}64+65+func drawSudokuGridLines(pdf *fpdf.Fpdf, startX, startY, cellSize float64) {66+ pdf.SetDrawColor(25, 25, 25)67+68+ for i := range 10 {69+ x := startX + float64(i)*cellSize70+ pdf.SetLineWidth(sudokuLineWidthFor(i))71+ pdf.Line(x, startY, x, startY+9.0*cellSize)72+ }73+74+ for i := range 10 {75+ y := startY + float64(i)*cellSize76+ pdf.SetLineWidth(sudokuLineWidthFor(i))77+ pdf.Line(startX, y, startX+9.0*cellSize, y)78+ }79+}80+81+func sudokuLineWidthFor(index int) float64 {82+ switch {83+ case index == 0 || index == 9:84+ return 0.7285+ case index%3 == 0:86+ return 0.5687+ default:88+ return pdfexport.ThinGridLineMM89+ }90+}91+92+func drawSudokuRGBGivens(pdf *fpdf.Fpdf, startX, startY, cellSize float64, givens [9][9]int) {93+ fontSize := pdfexport.StandardCellFontSize(cellSize, 0.62)94+ lineH := fontSize * 0.8595+ pdf.SetFont(pdfexport.SansFontFamily, "B", fontSize)96+ pdf.SetTextColor(pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray)97+98+ for y := range 9 {99+ for x := range 9 {100+ value := givens[y][x]101+ if value < 1 || value > valueCount {102+ continue103+ }104+ cellX := startX + float64(x)*cellSize105+ cellY := startY + float64(y)*cellSize106+ pdf.SetXY(cellX, cellY+(cellSize-lineH)/2)107+ pdf.CellFormat(cellSize, lineH, strconv.Itoa(value), "", 0, "C", false, 0, "")108+ }109+ }110+}111+112+func init() {113+ game.RegisterPrintAdapter(printAdapter{})114+}@@ -0,0 +1,52 @@
1+# Sudoku RGB2+3+Sudoku RGB is a 9x9 multiset-constraint puzzle inspired by Ripeto.4+5+## How to Play6+7+Fill every cell with one of three values:8+9+- `1` = `▲`10+- `2` = `■`11+- `3` = `●`12+13+The goal is for every row, every column, and every 3x3 box to contain the14+same multiset:15+16+`{1,1,1,2,2,2,3,3,3}`17+18+Unlike classic Sudoku, repeated values are allowed within a house up to their19+quota of three. A conflict appears only when a house contains **more than**20+three copies of a value.21+22+## Controls23+24+| Key | Action |25+|-----|--------|26+| Arrow keys / WASD / hjkl | Move cursor |27+| Mouse left-click | Focus a cell |28+| `1` / `2` / `3` | Place `▲` / `■` / `●` |29+| `Backspace` | Clear cell |30+| `Ctrl+R` | Reset puzzle |31+| `Ctrl+H` | Toggle full help |32+| `Ctrl+E` | Toggle debug overlay |33+| `Escape` | Return to main menu |34+35+## Modes36+37+| Mode | Clues | Description |38+|------|-------|-------------|39+| Beginner | 60 | Gentle intro to RGB quota logic |40+| Easy | 54 | Early rows and boxes resolve quickly |41+| Medium | 48 | Mixed row, column, and box pressure |42+| Hard | 42 | More ambiguous houses and cross-checking |43+| Expert | 36 | Sparse givens require deeper scanning |44+| Diabolical | 30 | Tightest clue budget in the launch set |45+46+## Quick Start47+48+```bash49+puzzletea new "sudoku rgb" beginner50+puzzletea new ripeto medium51+puzzletea new "rgb sudoku" diabolical52+```@@ -0,0 +1,103 @@
1+package sudokurgb2+3+import "fmt"4+5+type cell struct {6+ x, y int7+ v int8+}9+10+type grid = [gridSize][gridSize]cell11+12+func newGrid(provided []cell) grid {13+ var g grid14+ for y := range gridSize {15+ for x := range gridSize {16+ g[y][x] = cell{x: x, y: y}17+ }18+ }19+ for _, hint := range provided {20+ g[hint.y][hint.x].v = hint.v21+ }22+ return g23+}24+25+func (c cell) String() string {26+ return fmt.Sprintf("r%dc%d=%d", c.y, c.x, c.v)27+}28+29+func (m Model) isSolved() bool {30+ return isSolvedWith(m.grid, m.conflicts)31+}32+33+func isSolvedWith(g grid, conflicts [gridSize][gridSize]bool) bool {34+ for y := range gridSize {35+ for x := range gridSize {36+ if g[y][x].v == 0 || conflicts[y][x] {37+ return false38+ }39+ }40+ }41+42+ for i := range gridSize {43+ if !houseHasExactQuota(rowValues(g, i)) {44+ return false45+ }46+ if !houseHasExactQuota(colValues(g, i)) {47+ return false48+ }49+ }50+51+ for box := range gridSize {52+ if !houseHasExactQuota(boxValues(g, box)) {53+ return false54+ }55+ }56+57+ return true58+}59+60+func rowValues(g grid, y int) [gridSize]int {61+ var values [gridSize]int62+ for x := range gridSize {63+ values[x] = g[y][x].v64+ }65+ return values66+}67+68+func colValues(g grid, x int) [gridSize]int {69+ var values [gridSize]int70+ for y := range gridSize {71+ values[y] = g[y][x].v72+ }73+ return values74+}75+76+func boxValues(g grid, box int) [gridSize]int {77+ var values [gridSize]int78+ boxX, boxY := (box%3)*3, (box/3)*379+ index := 080+ for dy := range 3 {81+ for dx := range 3 {82+ values[index] = g[boxY+dy][boxX+dx].v83+ index++84+ }85+ }86+ return values87+}88+89+func houseHasExactQuota(values [gridSize]int) bool {90+ var counts [valueCount + 1]int91+ for _, value := range values {92+ if value < 1 || value > valueCount {93+ return false94+ }95+ counts[value]++96+ }97+ for value := 1; value <= valueCount; value++ {98+ if counts[value] != houseQuota {99+ return false100+ }101+ }102+ return true103+}@@ -0,0 +1,338 @@
1+package sudokurgb2+3+import "math/rand/v2"4+5+type quotaState struct {6+ row [gridSize][valueCount + 1]uint87+ col [gridSize][valueCount + 1]uint88+ box [gridSize][valueCount + 1]uint89+}10+11+type pos struct{ x, y int }12+13+func newRNG() *rand.Rand {14+ return rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))15+}16+17+func boxIndex(x, y int) int {18+ return (y/3)*3 + (x / 3)19+}20+21+func buildQuotaState(g *grid) (quotaState, bool) {22+ var state quotaState23+ for i := range gridSize {24+ for value := 1; value <= valueCount; value++ {25+ state.row[i][value] = houseQuota26+ state.col[i][value] = houseQuota27+ state.box[i][value] = houseQuota28+ }29+ }30+31+ for y := range gridSize {32+ for x := range gridSize {33+ value := g[y][x].v34+ if value == 0 {35+ continue36+ }37+ if value < 1 || value > valueCount {38+ return quotaState{}, false39+ }40+41+ box := boxIndex(x, y)42+ if state.row[y][value] == 0 || state.col[x][value] == 0 || state.box[box][value] == 0 {43+ return quotaState{}, false44+ }45+46+ state.row[y][value]--47+ state.col[x][value]--48+ state.box[box][value]--49+ }50+ }51+52+ return state, true53+}54+55+func canPlace(state *quotaState, value, x, y int) bool {56+ box := boxIndex(x, y)57+ return state.row[y][value] > 0 && state.col[x][value] > 0 && state.box[box][value] > 058+}59+60+func placeValue(g *grid, state *quotaState, value, x, y int) {61+ box := boxIndex(x, y)62+ g[y][x].v = value63+ state.row[y][value]--64+ state.col[x][value]--65+ state.box[box][value]--66+}67+68+func clearValue(g *grid, state *quotaState, value, x, y int) {69+ box := boxIndex(x, y)70+ g[y][x].v = 071+ state.row[y][value]++72+ state.col[x][value]++73+ state.box[box][value]++74+}75+76+func candidateMaskForCell(state *quotaState, x, y int) uint8 {77+ var mask uint878+ for value := 1; value <= valueCount; value++ {79+ if canPlace(state, value, x, y) {80+ mask |= 1 << value81+ }82+ }83+ return mask84+}85+86+func countCandidateBits(mask uint8) int {87+ count := 088+ for value := 1; value <= valueCount; value++ {89+ if mask&(1<<value) != 0 {90+ count++91+ }92+ }93+ return count94+}95+96+func findMostConstrainedEmptyCell(g *grid, state *quotaState) (x, y int, candidates uint8, found bool) {97+ bestCount := valueCount + 198+ bestX, bestY := 0, 099+ for y := range gridSize {100+ for x := range gridSize {101+ if g[y][x].v != 0 {102+ continue103+ }104+105+ mask := candidateMaskForCell(state, x, y)106+ candidateCount := countCandidateBits(mask)107+ if candidateCount == 0 {108+ return x, y, 0, true109+ }110+ if !found || candidateCount < bestCount {111+ found = true112+ bestCount = candidateCount113+ bestX, bestY = x, y114+ candidates = mask115+ if candidateCount == 1 {116+ return bestX, bestY, candidates, true117+ }118+ }119+ }120+ }121+ return bestX, bestY, candidates, found122+}123+124+func randomCandidateOrder(mask uint8, rng *rand.Rand) []int {125+ values := make([]int, 0, valueCount)126+ for value := 1; value <= valueCount; value++ {127+ if mask&(1<<value) != 0 {128+ values = append(values, value)129+ }130+ }131+ rng.Shuffle(len(values), func(i, j int) {132+ values[i], values[j] = values[j], values[i]133+ })134+ return values135+}136+137+func fillGridRec(g *grid, rng *rand.Rand, state *quotaState) bool {138+ x, y, candidates, found := findMostConstrainedEmptyCell(g, state)139+ if !found {140+ return true141+ }142+ if candidates == 0 {143+ return false144+ }145+146+ for _, value := range randomCandidateOrder(candidates, rng) {147+ placeValue(g, state, value, x, y)148+ if fillGridRec(g, rng, state) {149+ return true150+ }151+ clearValue(g, state, value, x, y)152+ }153+154+ return false155+}156+157+func fillGrid(g *grid) bool {158+ return fillGridSeeded(g, newRNG())159+}160+161+func fillGridSeeded(g *grid, rng *rand.Rand) bool {162+ state, ok := buildQuotaState(g)163+ if !ok {164+ return false165+ }166+ return fillGridRec(g, rng, &state)167+}168+169+func countSolutions(g *grid, limit int) int {170+ if limit <= 0 {171+ return 0172+ }173+174+ state, ok := buildQuotaState(g)175+ if !ok {176+ return 0177+ }178+ return countSolutionsRec(g, limit, &state)179+}180+181+func countSolutionsRec(g *grid, limit int, state *quotaState) int {182+ if limit <= 0 {183+ return 0184+ }185+186+ x, y, candidates, found := findMostConstrainedEmptyCell(g, state)187+ if !found {188+ return 1189+ }190+ if candidates == 0 {191+ return 0192+ }193+194+ count := 0195+ for value := 1; value <= valueCount; value++ {196+ if candidates&(1<<value) == 0 {197+ continue198+ }199+ placeValue(g, state, value, x, y)200+ count += countSolutionsRec(g, limit-count, state)201+ clearValue(g, state, value, x, y)202+ if count >= limit {203+ return count204+ }205+ }206+ return count207+}208+209+func GenerateProvidedCells(mode SudokuRGBMode) []cell {210+ return GenerateProvidedCellsSeeded(mode, newRNG())211+}212+213+func clueRemovalScore(g *grid, x, y int) int {214+ score := 0215+ for i := range gridSize {216+ if i != x && g[y][i].v == 0 {217+ score++218+ }219+ if i != y && g[i][x].v == 0 {220+ score++221+ }222+ }223+224+ boxX, boxY := (x/3)*3, (y/3)*3225+ for dy := range 3 {226+ for dx := range 3 {227+ cx, cy := boxX+dx, boxY+dy228+ if (cx != x || cy != y) && g[cy][cx].v == 0 {229+ score++230+ }231+ }232+ }233+ return score234+}235+236+func pickRemovalCandidateIndex(g *grid, positions []pos, rng *rand.Rand) int {237+ bestIndex := 0238+ bestScore := clueRemovalScore(g, positions[0].x, positions[0].y)239+ for i := 1; i < len(positions); i++ {240+ score := clueRemovalScore(g, positions[i].x, positions[i].y)241+ if score < bestScore || (score == bestScore && rng.IntN(2) == 0) {242+ bestScore = score243+ bestIndex = i244+ }245+ }246+ return bestIndex247+}248+249+func collectFilledCells(g *grid) []cell {250+ cells := make([]cell, 0, gridSize*gridSize)251+ for y := range gridSize {252+ for x := range gridSize {253+ if g[y][x].v != 0 {254+ cells = append(cells, g[y][x])255+ }256+ }257+ }258+ return cells259+}260+261+func GenerateProvidedCellsSeeded(mode SudokuRGBMode, rng *rand.Rand) []cell {262+ bestCells := []cell{}263+ bestCount := gridSize*gridSize + 1264+265+ for attempt := 0; attempt < 96; attempt++ {266+ g := newGrid(nil)267+ if !fillGridSeeded(&g, rng) {268+ continue269+ }270+271+ positions := make([]pos, 0, gridSize*gridSize)272+ for y := range gridSize {273+ for x := range gridSize {274+ positions = append(positions, pos{x: x, y: y})275+ }276+ }277+ rng.Shuffle(len(positions), func(i, j int) {278+ positions[i], positions[j] = positions[j], positions[i]279+ })280+281+ filled := gridSize * gridSize282+ for len(positions) > 0 && filled > mode.ProvidedCount {283+ idx := pickRemovalCandidateIndex(&g, positions, rng)284+ p := positions[idx]285+ positions[idx] = positions[len(positions)-1]286+ positions = positions[:len(positions)-1]287+288+ saved := g[p.y][p.x].v289+ g[p.y][p.x].v = 0290+ if countSolutions(&g, 2) != 1 {291+ g[p.y][p.x].v = saved292+ continue293+ }294+ filled--295+ }296+297+ cells := collectFilledCells(&g)298+ if len(cells) == mode.ProvidedCount {299+ return cells300+ }301+ if len(cells) < bestCount {302+ bestCount = len(cells)303+ bestCells = cells304+ }305+ }306+307+ return bestCells308+}309+310+func isValid(g *grid, value, x, y int) bool {311+ if value < 1 || value > valueCount {312+ return false313+ }314+315+ rowCount, colCount, boxCount := 0, 0, 0316+ boxX, boxY := (x/3)*3, (y/3)*3317+ for i := range gridSize {318+ if i != x && g[y][i].v == value {319+ rowCount++320+ }321+ if i != y && g[i][x].v == value {322+ colCount++323+ }324+ }325+ for dy := range 3 {326+ for dx := range 3 {327+ cx, cy := boxX+dx, boxY+dy328+ if cx == x && cy == y {329+ continue330+ }331+ if g[cy][cx].v == value {332+ boxCount++333+ }334+ }335+ }336+337+ return rowCount < houseQuota && colCount < houseQuota && boxCount < houseQuota338+}@@ -0,0 +1,35 @@
1+# Sudoku RGB2+3+Fill a 9x9 grid with RGB symbols so every row, column, and box matches the same multiset.4+5+## Rules6+7+- Each **row** must contain `{1,1,1,2,2,2,3,3,3}`.8+- Each **column** must contain `{1,1,1,2,2,2,3,3,3}`.9+- Each **3x3 box** must contain `{1,1,1,2,2,2,3,3,3}`.10+- `1`, `2`, and `3` map to `▲`, `■`, and `●`.11+- Pre-filled cells cannot be changed. Cells only conflict when a row, column,12+ or box contains **more than three** of one value.13+14+Inspired by Ripeto.15+16+## Controls17+18+| Key | Action |19+|-----|--------|20+| `Arrows` / `wasd` / `hjkl` | Move cursor |21+| `Mouse left-click` | Focus a cell |22+| `1` / `2` / `3` | Fill cell with `▲` / `■` / `●` |23+| `Backspace` | Clear cell |24+| `Ctrl+H` | Toggle help bar |25+| `Ctrl+R` | Reset puzzle |26+| `Escape` | Return to main menu |27+28+## Tips29+30+- **Count aggressively.** Every house wants exactly three of each value, so31+ once a row already has three `▲`, the rest of that row cannot be `▲`.32+- **Cross-check rows and boxes.** A value capped out in both the row and the33+ box removes options quickly.34+- **Use conflicts as quota warnings.** Red highlights mean a house already has35+ too many copies of a value.@@ -0,0 +1,46 @@
1+package sudokurgb2+3+import (4+ "charm.land/bubbles/v2/key"5+ "github.com/FelineStateMachine/puzzletea/game"6+)7+8+type KeyMap struct {9+ game.CursorKeyMap10+ FillValue key.Binding11+ ClearCell key.Binding12+}13+14+var DefaultKeyMap = KeyMap{15+ CursorKeyMap: game.DefaultCursorKeyMap,16+ FillValue: key.NewBinding(17+ key.WithKeys("1", "2", "3"),18+ key.WithHelp("1-3", "Fill"),19+ ),20+ ClearCell: key.NewBinding(21+ key.WithKeys("backspace"),22+ key.WithHelp("bkspc", "Clear"),23+ ),24+}25+26+func (m *Model) updateKeyBindings() {27+ m.keys.Up.SetEnabled(m.cursor.Y > 0)28+ m.keys.Down.SetEnabled(m.cursor.Y < gridSize-1)29+ m.keys.Left.SetEnabled(m.cursor.X > 0)30+ m.keys.Right.SetEnabled(m.cursor.X < gridSize-1)31+32+ allowInteraction := !m.IsProvidedCell()33+ m.keys.FillValue.SetEnabled(allowInteraction)34+ m.keys.ClearCell.SetEnabled(allowInteraction)35+}36+37+func (m Model) IsProvidedCell() bool {38+ return m.providedGrid[m.cursor.Y][m.cursor.X]39+}40+41+func (m Model) GetFullHelp() [][]key.Binding {42+ return [][]key.Binding{43+ {m.keys.Up, m.keys.Down, m.keys.Left, m.keys.Right},44+ {m.keys.FillValue, m.keys.ClearCell},45+ }46+}@@ -0,0 +1,33 @@
1+package sudokurgb2+3+import "github.com/FelineStateMachine/puzzletea/game"4+5+func (m *Model) screenToGrid(screenX, screenY int) (col, row int, ok bool) {6+ ox, oy := m.cachedGridOrigin()7+ return game.DynamicGridScreenToCell(8+ game.DynamicGridMetrics{Width: gridSize, Height: gridSize, CellWidth: cellWidth},9+ ox,10+ oy,11+ screenX,12+ screenY,13+ false,14+ )15+}16+17+func (m *Model) cachedGridOrigin() (x, y int) {18+ if m.originValid {19+ return m.originX, m.originY20+ }21+22+ x, y = m.gridOrigin()23+ m.originX, m.originY = x, y24+ m.originValid = true25+ return x, y26+}27+28+func (m *Model) gridOrigin() (x, y int) {29+ solved := m.isSolved()30+ title := game.TitleBarView("Sudoku RGB", m.modeTitle, solved)31+ grid := renderGrid(*m, solved)32+ return game.DynamicGridOrigin(m.termWidth, m.termHeight, m.View(), title, grid)33+}@@ -0,0 +1,224 @@
1+package sudokurgb2+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+func emptyCellStyle() lipgloss.Style {14+ p := theme.Current()15+ return lipgloss.NewStyle().16+ Foreground(p.TextDim).17+ Background(game.DefaultBorderColors().BackgroundBG)18+}19+20+func providedCellStyle() lipgloss.Style {21+ return lipgloss.NewStyle().22+ Bold(true).23+ Background(game.DefaultBorderColors().BackgroundBG)24+}25+26+func userCellStyle(value int) lipgloss.Style {27+ return lipgloss.NewStyle().28+ Foreground(symbolColor(value))29+}30+31+func conflictCellStyle() lipgloss.Style {32+ p := theme.Current()33+ return lipgloss.NewStyle().34+ Foreground(p.Error).35+ Underline(true)36+}37+38+func valueCursorStyle() lipgloss.Style {39+ return lipgloss.NewStyle().40+ Bold(true)41+}42+43+func renderGrid(m Model, solved bool) string {44+ return game.RenderDynamicGrid(game.DynamicGridSpec{45+ Width: gridSize,46+ Height: gridSize,47+ Solved: solved,48+ Cell: func(x, y int) string {49+ return cellView(m, x, y, solved)50+ },51+ ZoneAt: func(x, y int) int {52+ return sudokuBoxIndex(x, y)53+ },54+ ZoneFill: func(zone int) color.Color {55+ return activeBoxZoneFill(m.cursor, solved, zone)56+ },57+ BridgeFill: func(bridge game.DynamicGridBridge) color.Color {58+ return bridgeFill(m, solved, bridge)59+ },60+ })61+}62+63+func cellView(m Model, x, y int, solved bool) string {64+ c := m.grid[y][x]65+ style := cellStyle(m, c, x, y, m.conflicts[y][x], solved)66+ text := cellContent(c)67+ if x == m.cursor.X && y == m.cursor.Y {68+ if c.v == 0 {69+ text = game.CursorLeft + "·" + game.CursorRight70+ } else {71+ text = game.CursorLeft + cellContentValue(c.v) + game.CursorRight72+ }73+ }74+75+ return style.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(text)76+}77+78+func cellStyle(m Model, c cell, x, y int, conflict, solved bool) lipgloss.Style {79+ isCursor := m.cursor.X == x && m.cursor.Y == y80+ base := lipgloss.NewStyle()81+82+ switch c.v {83+ case 0:84+ base = emptyCellStyle()85+ default:86+ base = userCellStyle(c.v)87+ }88+89+ if m.providedGrid[y][x] {90+ base = providedCellStyle()91+ if c.v != 0 {92+ base = base.Foreground(symbolColor(c.v))93+ }94+ }95+96+ switch {97+ case conflict:98+ base = base.Inherit(conflictCellStyle())99+ case solved:100+ base = base.Bold(true)101+ }102+103+ if isCursor {104+ base = base.Inherit(valueCursorStyle())105+ }106+107+ return base108+}109+110+func bridgeFill(m Model, solved bool, bridge game.DynamicGridBridge) color.Color {111+ return nil112+}113+114+func activeBoxZoneFill(cursor game.Cursor, solved bool, zone int) color.Color {115+ return nil116+}117+118+func sudokuBoxIndex(x, y int) int {119+ return (y / 3 * 3) + (x / 3)120+}121+122+func cellContent(c cell) string {123+ if c.v == 0 {124+ return "·"125+ }126+ return cellContentValue(c.v)127+}128+129+func cellContentValue(value int) string {130+ switch value {131+ case 1:132+ return "▲"133+ case 2:134+ return "■"135+ case 3:136+ return "●"137+ default:138+ return "·"139+ }140+}141+142+func symbolColor(value int) color.Color {143+ p := theme.Current()144+ switch value {145+ case 1:146+ return p.Error147+ case 2:148+ return p.Success149+ case 3:150+ return p.Secondary151+ default:152+ return p.TextDim153+ }154+}155+156+func computeConflicts(g grid) [gridSize][gridSize]bool {157+ var conflicts [gridSize][gridSize]bool158+159+ for y := range gridSize {160+ var seen [valueCount + 1][]int161+ for x := range gridSize {162+ value := g[y][x].v163+ if value != 0 {164+ seen[value] = append(seen[value], x)165+ }166+ }167+ for value := 1; value <= valueCount; value++ {168+ if len(seen[value]) > houseQuota {169+ for _, x := range seen[value] {170+ conflicts[y][x] = true171+ }172+ }173+ }174+ }175+176+ for x := range gridSize {177+ var seen [valueCount + 1][]int178+ for y := range gridSize {179+ value := g[y][x].v180+ if value != 0 {181+ seen[value] = append(seen[value], y)182+ }183+ }184+ for value := 1; value <= valueCount; value++ {185+ if len(seen[value]) > houseQuota {186+ for _, y := range seen[value] {187+ conflicts[y][x] = true188+ }189+ }190+ }191+ }192+193+ for boxY := range 3 {194+ for boxX := range 3 {195+ type pos struct{ x, y int }196+ var seen [valueCount + 1][]pos197+ for dy := range 3 {198+ for dx := range 3 {199+ x, y := boxX*3+dx, boxY*3+dy200+ value := g[y][x].v201+ if value != 0 {202+ seen[value] = append(seen[value], pos{x: x, y: y})203+ }204+ }205+ }206+ for value := 1; value <= valueCount; value++ {207+ if len(seen[value]) > houseQuota {208+ for _, p := range seen[value] {209+ conflicts[p.y][p.x] = true210+ }211+ }212+ }213+ }214+ }215+216+ return conflicts217+}218+219+func statusBarView(showFullHelp bool) string {220+ if showFullHelp {221+ return game.StatusBarStyle().Render("mouse: click focus arrows/wasd: move 1/2/3: ▲■● bkspc: clear esc: menu ctrl+r: reset ctrl+h: help")222+ }223+ return game.StatusBarStyle().Render("mouse: click focus 1/2/3: ▲■● bkspc: clear")224+}@@ -0,0 +1,176 @@
1+package sudokurgb2+3+import (4+ "math/rand/v2"5+ "strings"6+ "testing"7+8+ tea "charm.land/bubbletea/v2"9+ "github.com/FelineStateMachine/puzzletea/game"10+ "github.com/charmbracelet/x/ansi"11+)12+13+func validCompleteGrid() grid {14+ rows := [gridSize][gridSize]int{15+ {1, 2, 3, 1, 2, 3, 1, 2, 3},16+ {2, 3, 1, 2, 3, 1, 2, 3, 1},17+ {3, 1, 2, 3, 1, 2, 3, 1, 2},18+ {1, 2, 3, 1, 2, 3, 1, 2, 3},19+ {2, 3, 1, 2, 3, 1, 2, 3, 1},20+ {3, 1, 2, 3, 1, 2, 3, 1, 2},21+ {1, 2, 3, 1, 2, 3, 1, 2, 3},22+ {2, 3, 1, 2, 3, 1, 2, 3, 1},23+ {3, 1, 2, 3, 1, 2, 3, 1, 2},24+ }25+26+ var g grid27+ for y := range gridSize {28+ for x := range gridSize {29+ g[y][x] = cell{x: x, y: y, v: rows[y][x]}30+ }31+ }32+ return g33+}34+35+func TestFillGridSeededProducesValidBoard(t *testing.T) {36+ g := newGrid(nil)37+ if !fillGridSeeded(&g, rand.New(rand.NewPCG(7, 11))) {38+ t.Fatal("fillGridSeeded returned false")39+ }40+ if conflicts := computeConflicts(g); !isSolvedWith(g, conflicts) {41+ t.Fatal("generated board is not solved")42+ }43+}44+45+func TestCountSolutionsRejectsOverQuotaState(t *testing.T) {46+ g := validCompleteGrid()47+ g[0][1].v = 148+ if got := countSolutions(&g, 2); got != 0 {49+ t.Fatalf("countSolutions(overquota) = %d, want 0", got)50+ }51+}52+53+func TestGenerateProvidedCellsSeededMatchesModeTargets(t *testing.T) {54+ for i, item := range Modes {55+ mode := item.(SudokuRGBMode)56+ t.Run(mode.Title(), func(t *testing.T) {57+ rng := rand.New(rand.NewPCG(uint64(i+1), uint64(i+101)))58+ cells := GenerateProvidedCellsSeeded(mode, rng)59+ if got, want := len(cells), mode.ProvidedCount; got != want {60+ t.Fatalf("provided count = %d, want %d", got, want)61+ }62+63+ g := newGrid(cells)64+ if got := countSolutions(&g, 2); got != 1 {65+ t.Fatalf("countSolutions() = %d, want 1", got)66+ }67+ })68+ }69+}70+71+func TestModelUpdateRestrictsInputsAndRespectsProvidedCells(t *testing.T) {72+ model := Model{73+ grid: newGrid([]cell{{x: 0, y: 0, v: 1}}),74+ provided: []cell{{x: 0, y: 0, v: 1}},75+ providedGrid: buildProvidedGrid([]cell{{x: 0, y: 0, v: 1}}),76+ conflicts: computeConflicts(newGrid([]cell{{x: 0, y: 0, v: 1}})),77+ keys: DefaultKeyMap,78+ cursor: game.Cursor{X: 1, Y: 1},79+ }80+81+ next, _ := model.Update(tea.KeyPressMsg{Code: '1', Text: "1"})82+ got := next.(Model)83+ if got.grid[1][1].v != 1 {84+ t.Fatalf("value after 1 = %d, want 1", got.grid[1][1].v)85+ }86+87+ next, _ = got.Update(tea.KeyPressMsg{Code: '4', Text: "4"})88+ got = next.(Model)89+ if got.grid[1][1].v != 1 {90+ t.Fatalf("value after ignored 4 = %d, want 1", got.grid[1][1].v)91+ }92+93+ next, _ = got.Update(tea.KeyPressMsg{Code: tea.KeyBackspace})94+ got = next.(Model)95+ if got.grid[1][1].v != 0 {96+ t.Fatalf("value after backspace = %d, want 0", got.grid[1][1].v)97+ }98+99+ got.cursor.X, got.cursor.Y = 0, 0100+ next, _ = got.Update(tea.KeyPressMsg{Code: '2', Text: "2"})101+ got = next.(Model)102+ if got.grid[0][0].v != 1 {103+ t.Fatalf("provided cell mutated to %d, want 1", got.grid[0][0].v)104+ }105+}106+107+func TestCellViewUsesUnicodeSymbolsWithCursorMarkers(t *testing.T) {108+ g := newGrid(nil)109+ g[0][0].v = 3110+ m := Model{111+ grid: g,112+ keys: DefaultKeyMap,113+ cursor: game.Cursor{X: 0, Y: 0},114+ }115+116+ got := ansi.Strip(cellView(m, 0, 0, false))117+ if !strings.Contains(got, game.CursorLeft+"●"+game.CursorRight) {118+ t.Fatalf("filled cursor cell = %q, want marker-wrapped unicode symbol", got)119+ }120+121+ g[0][0].v = 0122+ m.grid = g123+ got = ansi.Strip(cellView(m, 0, 0, false))124+ if !strings.Contains(got, game.CursorLeft+"·"+game.CursorRight) {125+ t.Fatalf("empty cursor cell = %q, want marker-wrapped dot", got)126+ }127+}128+129+func TestScreenToGridUsesStandardCellWidth(t *testing.T) {130+ m := Model{131+ grid: newGrid(nil),132+ conflicts: computeConflicts(newGrid(nil)),133+ keys: DefaultKeyMap,134+ }135+136+ next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})137+ got := next.(Model)138+ x, y := got.cachedGridOrigin()139+140+ if col, row, ok := got.screenToGrid(x, y); !ok || col != 0 || row != 0 {141+ t.Fatalf("screenToGrid(origin) = (%d,%d,%v), want (0,0,true)", col, row, ok)142+ }143+ if _, _, ok := got.screenToGrid(x+cellWidth, y); ok {144+ t.Fatal("screenToGrid(separator) = ok, want false")145+ }146+}147+148+func TestSaveRoundTrip(t *testing.T) {149+ g := newGrid([]cell{{x: 0, y: 0, v: 1}})150+ g[1][1].v = 3151+152+ saveModel := Model{153+ grid: g,154+ provided: []cell{{x: 0, y: 0, v: 1}},155+ providedGrid: buildProvidedGrid([]cell{{x: 0, y: 0, v: 1}}),156+ conflicts: computeConflicts(g),157+ keys: DefaultKeyMap,158+ modeTitle: "Easy",159+ }160+161+ save, err := saveModel.GetSave()162+ if err != nil {163+ t.Fatal(err)164+ }165+166+ loaded, err := ImportModel(save)167+ if err != nil {168+ t.Fatal(err)169+ }170+ if loaded.grid[0][0].v != 1 || loaded.grid[1][1].v != 3 {171+ t.Fatalf("loaded grid values = (%d,%d), want (1,3)", loaded.grid[0][0].v, loaded.grid[1][1].v)172+ }173+ if loaded.modeTitle != "Easy" {174+ t.Fatalf("mode title = %q, want Easy", loaded.modeTitle)175+ }176+}@@ -129,3 +129,18 @@ func TestBonusXP(t *testing.T) {
129129 })130130 }131131 }132+133+func TestEligibleModesIncludeSudokuRGBDailyTiers(t *testing.T) {134+ found := map[string]bool{}135+ for _, entry := range eligibleModes {136+ if entry.GameType == "Sudoku RGB" {137+ found[entry.Mode] = true138+ }139+ }140+141+ for _, mode := range []string{"Easy", "Medium"} {142+ if !found[mode] {143+ t.Fatalf("eligibleModes missing Sudoku RGB %s", mode)144+ }145+ }146+}