- commit
- f6b7327e6785db421e1856dec4e55082e7f87cc6
- parent
- f068e922edd005712c70181e0e3ef085dafd0a41
- author
- Dami <dami.etoile@gmail.com>
@@ -2,13 +2,13 @@
22 33 A terminal-based puzzle game collection built with [Bubble Tea](https://github.com/charmbracelet/bubbletea).44 5-Nine puzzle types, multiple difficulty modes, daily challenges, XP progression, 365 color themes, and an explicit game catalog for adding new games.5+Eleven puzzle types, multiple difficulty modes, daily challenges, XP progression, 365 color themes, and an explicit game catalog for adding new games.66 77 88 99 ## Features1010 11-- **9 puzzle games** -- Nonogram, Nurikabe, Sudoku, Shikaku, Word Search, Hashiwokakero, Hitori, Lights Out, Takuzu11+- **11 puzzle games** -- Fillomino, Nonogram, Nurikabe, Ripple Effect, Sudoku, Shikaku, Word Search, Hashiwokakero, Hitori, Lights Out, Takuzu1212 - **Daily puzzles** -- A unique puzzle generated each day using deterministic seeding. Same date, same puzzle for everyone. Streak tracking rewards consecutive daily completions.1313 - **XP and leveling** -- Per-category levels based on victories. Harder modes yield more XP. Daily puzzles grant 2x XP.1414 - **Stats dashboard** -- Profile level, daily streak, victory counts, and XP progress bars per category.@@ -21,8 +21,10 @@ Nine puzzle types, multiple difficulty modes, daily challenges, XP progression,
2121 2222 | Game | Description | Modes |2323 |------|-------------|-------|24+| **Fillomino** | Grow numbered regions to their exact sizes | Mini 5x5 through Expert 12x12 |2425 | **Nonogram** | Fill cells to match row and column hints | Easy/Medium/Hard across 5x5, 10x10, 15x15, 20x20 |2526 | **Nurikabe** | Build islands while keeping one connected sea | 5 modes from 5x5 to 12x12 |27+| **Ripple Effect** | Place digits in cages without violating ripple distance | Mini 5x5 through Expert 9x9 |2628 | **Shikaku** | Divide grid into rectangles matching cell counts | 5 modes from 7x7 to 11x11 |2729 | **Sudoku** | Classic 9x9 grid | Beginner, Easy, Medium, Hard, Expert, Diabolical |2830 | **Word Search** | Find hidden words in a letter grid | Easy, Medium, Hard (3-8 directions) |@@ -83,6 +85,8 @@ Start a new game directly:
8385 8486 ```bash8587 puzzletea new nonogram medium88+puzzletea new fillomino "Hard 10x10"89+puzzletea new ripple-effect "Medium 7x7"8690 puzzletea new sudoku hard8791 puzzletea new lights-out8892 puzzletea new hashi easy@@ -153,7 +157,7 @@ puzzletea --continue amber-falcon
153157 154158 ### CLI Aliases155159 156-Several shorthand names are accepted for games: `hashi`/`bridges` for Hashiwokakero, `lights` for Lights Out, `islands`/`sea` for Nurikabe, `binairo`/`binary` for Takuzu, `words`/`ws` for Word Search, `rectangles` for Shikaku.160+Several shorthand names are accepted for games: `polyomino`/`regions` for Fillomino, `hashi`/`bridges` for Hashiwokakero, `lights` for Lights Out, `islands`/`sea` for Nurikabe, `ripple` for Ripple Effect, `binairo`/`binary` for Takuzu, `words`/`ws` for Word Search, `rectangles` for Shikaku.157161 158162 ## Controls159163 @@ -183,6 +187,20 @@ Games are automatically saved to `~/.puzzletea/history.db` (SQLite). Navigating
183187 184188 ## Previews185189 190+### Fillomino191+Grow numbered regions so each connected region reaches its exact size.192+193+194+195+[Game details and controls](fillomino/README.md)196+197+### Ripple Effect198+Place digits in cages while keeping matching values outside each other’s ripple distance.199+200+201+202+[Game details and controls](rippleeffect/README.md)203+186204 ### Nonogram187205 Fill cells to match row and column hints.188206 @@ -4,12 +4,14 @@ import (
44 "fmt"55 66 "charm.land/bubbles/v2/list"7+ "github.com/FelineStateMachine/puzzletea/fillomino"78 "github.com/FelineStateMachine/puzzletea/game"89 "github.com/FelineStateMachine/puzzletea/hashiwokakero"910 "github.com/FelineStateMachine/puzzletea/hitori"1011 "github.com/FelineStateMachine/puzzletea/lightsout"1112 "github.com/FelineStateMachine/puzzletea/nonogram"1213 "github.com/FelineStateMachine/puzzletea/nurikabe"14+ "github.com/FelineStateMachine/puzzletea/rippleeffect"1315 "github.com/FelineStateMachine/puzzletea/shikaku"1416 "github.com/FelineStateMachine/puzzletea/sudoku"1517 "github.com/FelineStateMachine/puzzletea/takuzu"@@ -18,11 +20,13 @@ import (
1820 1921 // All is the explicit ordered catalog of every puzzle game.2022 var All = []game.Definition{23+ fillomino.Definition,2124 hashiwokakero.Definition,2225 hitori.Definition,2326 lightsout.Definition,2427 nonogram.Definition,2528 nurikabe.Definition,29+ rippleeffect.Definition,2630 shikaku.Definition,2731 sudoku.Definition,2832 takuzu.Definition,@@ -199,6 +203,16 @@ func Resolve(name string) (game.Definition, bool) {
199203 return Lookup(canonical)200204 }201205 206+// Category resolves a canonical game name or alias to the menu-facing category.207+func Category(name string) (game.Category, error) {208+ def, ok := Resolve(name)209+ if !ok {210+ return game.Category{}, fmt.Errorf("unknown game %q\n\nAvailable games:\n %s",211+ name, joinLines(Names()))212+ }213+ return def.Category(), nil214+}215+202216 // Import reconstructs a saved game for the given canonical game type.203217 func Import(gameType string, data []byte) (game.Gamer, error) {204218 def, ok := Lookup(gameType)@@ -211,3 +225,15 @@ func Import(gameType string, data []byte) (game.Gamer, error) {
211225 }212226 return g, nil213227 }228+229+func joinLines(values []string) string {230+ if len(values) == 0 {231+ return ""232+ }233+234+ result := values[0]235+ for i := 1; i < len(values); i++ {236+ result += "\n " + values[i]237+ }238+ return result239+}@@ -1,6 +1,7 @@
11 package catalog22 33 import (4+ "strings"45 "testing"56 67 "github.com/FelineStateMachine/puzzletea/lightsout"@@ -18,8 +19,10 @@ func TestResolveSupportsCanonicalNamesAndAliases(t *testing.T) {
1819 want string1920 }{2021 {input: "Sudoku", want: "Sudoku"},22+ {input: "fillomino", want: "Fillomino"},2123 {input: "hashi", want: "Hashiwokakero"},2224 {input: "lights", want: "Lights Out"},25+ {input: "ripple", want: "Ripple Effect"},2326 {input: "wordsearch", want: "Word Search"},2427 }2528 @@ -34,6 +37,38 @@ func TestResolveSupportsCanonicalNamesAndAliases(t *testing.T) {
3437 }3538 }3639 40+func TestCategorySupportsCanonicalNamesAndAliases(t *testing.T) {41+ tests := []struct {42+ input string43+ want string44+ }{45+ {input: "Sudoku", want: "Sudoku"},46+ {input: "lights-out", want: "Lights Out"},47+ {input: "polyomino", want: "Fillomino"},48+ {input: "wordsearch", want: "Word Search"},49+ }50+51+ for _, tt := range tests {52+ cat, err := Category(tt.input)53+ if err != nil {54+ t.Fatalf("Category(%q) error = %v", tt.input, err)55+ }56+ if cat.Name != tt.want {57+ t.Fatalf("Category(%q).Name = %q, want %q", tt.input, cat.Name, tt.want)58+ }59+ }60+}61+62+func TestCategoryUnknownGameIncludesAvailableGames(t *testing.T) {63+ _, err := Category("chess")64+ if err == nil {65+ t.Fatal("Category(chess) error = nil, want non-nil")66+ }67+ if got := err.Error(); got == "" || !containsAll(got, []string{"unknown game", "Available games:", "Sudoku"}) {68+ t.Fatalf("Category(chess) error = %q, want available games list", got)69+ }70+}71+3772 func TestCategoriesAndNamesPreserveCatalogOrder(t *testing.T) {3873 names := Names()3974 categories := Categories()@@ -53,6 +88,15 @@ func TestCategoriesAndNamesPreserveCatalogOrder(t *testing.T) {
5388 }5489 }5590 91+func containsAll(s string, parts []string) bool {92+ for _, part := range parts {93+ if !strings.Contains(s, part) {94+ return false95+ }96+ }97+ return true98+}99+56100 func TestImportRoundTrip(t *testing.T) {57101 g, err := lightsout.New(3, 3)58102 if err != nil {@@ -27,7 +27,7 @@ var newCmd = &cobra.Command{
2727 Short: "Start a new puzzle game",2828 Long: fmt.Sprintf("Start a new puzzle game, optionally specifying the difficulty mode.\n"+2929 "Use --set-seed to generate a deterministic puzzle from a seed string.\n\nAvailable games:\n %s",30- strings.Join(resolve.CategoryNames(catalog.All), "\n ")),30+ strings.Join(catalog.Names(), "\n ")),3131 Args: cobra.RangeArgs(0, 2),3232 RunE: func(cmd *cobra.Command, args []string) error {3333 if flagOutput != "" || cmd.Flags().Changed("export") {@@ -63,7 +63,7 @@ func init() {
6363 6464 // launchNewGame resolves the game/mode, spawns a new game, and launches the TUI.6565 func launchNewGame(gameArg, modeArg, seed string, cfg *config.Config) error {66- cat, err := resolve.Category(gameArg, catalog.All)66+ cat, err := catalog.Category(gameArg)6767 if err != nil {6868 return err6969 }@@ -31,7 +31,7 @@ func runNewExport(cmd *cobra.Command, args []string) error {
3131 return err3232 }3333 34- cat, err := resolve.Category(args[0], catalog.All)34+ cat, err := catalog.Category(args[0])3535 if err != nil {3636 return err3737 }@@ -0,0 +1,80 @@
1+package fillomino2+3+import (4+ "encoding/json"5+ "fmt"6+7+ "github.com/FelineStateMachine/puzzletea/game"8+)9+10+type Save struct {11+ Width int `json:"width"`12+ Height int `json:"height"`13+ State string `json:"state"`14+ Provided string `json:"provided"`15+ ModeTitle string `json:"mode_title"`16+}17+18+func (m Model) GetSave() ([]byte, error) {19+ save := Save{20+ Width: m.width,21+ Height: m.height,22+ State: encodeGrid(m.grid),23+ Provided: serializeProvided(m.provided),24+ ModeTitle: m.modeTitle,25+ }26+ data, err := json.Marshal(save)27+ if err != nil {28+ return nil, fmt.Errorf("marshal fillomino save: %w", err)29+ }30+ return data, nil31+}32+33+func ImportModel(data []byte) (*Model, error) {34+ var save Save35+ if err := json.Unmarshal(data, &save); err != nil {36+ return nil, fmt.Errorf("unmarshal fillomino save: %w", err)37+ }38+ if save.Width <= 0 || save.Height <= 0 {39+ return nil, fmt.Errorf("invalid fillomino size %dx%d", save.Width, save.Height)40+ }41+42+ state, err := parseGrid(save.State, save.Width, save.Height)43+ if err != nil {44+ return nil, fmt.Errorf("decode fillomino state: %w", err)45+ }46+ provided := deserializeProvided(save.Provided, save.Width, save.Height)47+ for y := range save.Height {48+ for x := range save.Width {49+ if provided[y][x] && state[y][x] == 0 {50+ return nil, fmt.Errorf("provided cell at (%d,%d) is empty", x, y)51+ }52+ }53+ }54+55+ m := &Model{56+ width: save.Width,57+ height: save.Height,58+ grid: state,59+ initialGrid: initialGridForImport(state, provided),60+ provided: provided,61+ cursor: game.Cursor{},62+ keys: DefaultKeyMap,63+ modeTitle: save.ModeTitle,64+ maxCellValue: max(9, maxCellValue(state)),65+ }66+ m.recompute()67+ return m, nil68+}69+70+func initialGridForImport(state grid, provided [][]bool) grid {71+ initial := newGrid(len(state[0]), len(state))72+ for y := range len(state) {73+ for x := range len(state[y]) {74+ if provided[y][x] {75+ initial[y][x] = state[y][x]76+ }77+ }78+ }79+ return initial80+}@@ -0,0 +1,74 @@
1+package fillomino2+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 Mode struct {15+ game.BaseMode16+ Size int17+ MaxRegion int18+ GivenRatio float6419+}20+21+var (22+ _ game.Mode = Mode{}23+ _ game.Spawner = Mode{}24+ _ game.SeededSpawner = Mode{}25+)26+27+func NewMode(title, description string, size, maxRegion int, givenRatio float64) Mode {28+ return Mode{29+ BaseMode: game.NewBaseMode(title, description),30+ Size: size,31+ MaxRegion: maxRegion,32+ GivenRatio: givenRatio,33+ }34+}35+36+func (m Mode) Spawn() (game.Gamer, error) {37+ puzzle, err := GeneratePuzzle(m.Size, m.Size, m.MaxRegion, m.GivenRatio)38+ if err != nil {39+ return nil, err40+ }41+ return New(m, puzzle)42+}43+44+func (m Mode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) {45+ puzzle, err := GeneratePuzzleSeeded(m.Size, m.Size, m.MaxRegion, m.GivenRatio, rng)46+ if err != nil {47+ return nil, err48+ }49+ return New(m, puzzle)50+}51+52+var Modes = []list.Item{53+ NewMode("Mini 5x5", "Small board with generous clues.", 5, 5, 0.70),54+ NewMode("Easy 6x6", "Compact board with simple regions.", 6, 6, 0.66),55+ NewMode("Medium 8x8", "Balanced deduction and region growth.", 8, 7, 0.60),56+ NewMode("Hard 10x10", "Larger board with denser interactions.", 10, 8, 0.56),57+ NewMode("Expert 12x12", "Wide board with long deduction chains.", 12, 9, 0.52),58+}59+60+var DailyModes = []list.Item{61+ Modes[1],62+ Modes[2],63+ Modes[3],64+}65+66+var Definition = game.Definition{67+ Name: "Fillomino",68+ Description: "Grow numbered regions to their exact sizes.",69+ Aliases: []string{"polyomino", "regions"},70+ Modes: Modes,71+ DailyModes: DailyModes,72+ Help: HelpContent,73+ Import: func(data []byte) (game.Gamer, error) { return ImportModel(data) },74+}@@ -0,0 +1,162 @@
1+package fillomino2+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+var _ game.Gamer = Model{}13+14+type Model struct {15+ width int16+ height int17+ grid grid18+ initialGrid grid19+ provided [][]bool20+ cursor game.Cursor21+ conflicts [][]bool22+ solved bool23+ keys KeyMap24+ modeTitle string25+ showFullHelp bool26+ maxCellValue int27+ termWidth int28+ termHeight int29+ originX int30+ originY int31+ originValid bool32+}33+34+func New(mode Mode, puzzle Puzzle) (game.Gamer, error) {35+ if puzzle.Width != mode.Size || puzzle.Height != mode.Size {36+ return nil, fmt.Errorf("fillomino puzzle size %dx%d does not match mode %dx%d", puzzle.Width, puzzle.Height, mode.Size, mode.Size)37+ }38+39+ m := Model{40+ width: puzzle.Width,41+ height: puzzle.Height,42+ grid: cloneGrid(puzzle.Givens),43+ initialGrid: cloneGrid(puzzle.Givens),44+ provided: newProvidedMask(puzzle.Givens),45+ cursor: game.Cursor{},46+ keys: DefaultKeyMap,47+ modeTitle: mode.Title(),48+ maxCellValue: mode.MaxRegion,49+ }50+ m.recompute()51+ return m, nil52+}53+54+func (m Model) Init() tea.Cmd {55+ return nil56+}57+58+func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {59+ switch msg := msg.(type) {60+ case game.HelpToggleMsg:61+ 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+ }74+ case tea.KeyPressMsg:75+ switch {76+ case key.Matches(msg, m.keys.Clear):77+ m.setCell(0)78+ case isDigitKey(msg.String()):79+ value, _ := strconv.Atoi(msg.String())80+ if value > 0 && value <= m.maxCellValue {81+ m.setCell(value)82+ }83+ default:84+ m.cursor.Move(m.keys.CursorKeyMap, msg, m.width-1, m.height-1)85+ }86+ }87+88+ m.updateKeyBindings()89+ return m, nil90+}91+92+func isDigitKey(s string) bool {93+ return len(s) == 1 && s[0] >= '1' && s[0] <= '9'94+}95+96+func (m *Model) setCell(value int) {97+ if m.solved || m.provided[m.cursor.Y][m.cursor.X] {98+ return99+ }100+ m.grid[m.cursor.Y][m.cursor.X] = value101+ m.recompute()102+}103+104+func (m *Model) recompute() {105+ result := validateGridState(m.grid)106+ m.conflicts = result.conflicts107+ m.solved = result.solved108+}109+110+func (m Model) View() string {111+ title := game.TitleBarView("Fillomino", m.modeTitle, m.solved)112+ body := gridView(m)113+ if m.solved {114+ return game.ComposeGameView(title, body)115+ }116+ status := statusBarView(m.showFullHelp)117+ return game.ComposeGameViewRows(title, body, game.StableRow(status, statusBarView(false), statusBarView(true)))118+}119+120+func (m Model) SetTitle(t string) game.Gamer {121+ m.modeTitle = t122+ return m123+}124+125+func (m Model) IsSolved() bool {126+ return m.solved127+}128+129+func (m Model) Reset() game.Gamer {130+ m.grid = cloneGrid(m.initialGrid)131+ m.cursor = game.Cursor{}132+ m.recompute()133+ return m134+}135+136+func (m Model) GetDebugInfo() string {137+ status := "In Progress"138+ if m.solved {139+ status = "Solved"140+ }141+142+ filled := 0143+ conflicts := 0144+ for y := range m.height {145+ for x := range m.width {146+ if m.grid[y][x] != 0 {147+ filled++148+ }149+ if m.conflicts[y][x] {150+ conflicts++151+ }152+ }153+ }154+155+ return game.DebugHeader("Fillomino", [][2]string{156+ {"Status", status},157+ {"Cursor", fmt.Sprintf("(%d, %d)", m.cursor.X, m.cursor.Y)},158+ {"Cells Filled", fmt.Sprintf("%d / %d", filled, m.width*m.height)},159+ {"Conflict Count", fmt.Sprintf("%d", conflicts)},160+ {"Max Region", fmt.Sprintf("%d", m.maxCellValue)},161+ })162+}@@ -0,0 +1,90 @@
1+package fillomino2+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 "Fillomino" }14+func (printAdapter) Aliases() []string { return []string{"fillomino", "polyomino", "regions"} }15+16+func (printAdapter) BuildPDFPayload(save []byte) (any, error) {17+ return pdfexport.ParseFillominoPrintData(save)18+}19+20+func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error {21+ switch data := payload.(type) {22+ case *pdfexport.FillominoData:23+ renderFillominoPage(pdf, data)24+ }25+ return nil26+}27+28+func renderFillominoPage(pdf *fpdf.Fpdf, data *pdfexport.FillominoData) {29+ if data == nil || data.Width <= 0 || data.Height <= 0 {30+ return31+ }32+33+ pageW, pageH := pdf.GetPageSize()34+ pageNo := pdf.PageNo()35+ area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, 1)36+ cellSize := pdfexport.FitCompactCellSize(data.Width, data.Height, area)37+ if cellSize <= 0 {38+ return39+ }40+41+ blockW := float64(data.Width) * cellSize42+ blockH := float64(data.Height) * cellSize43+ startX, startY := pdfexport.CenteredOrigin(area, data.Width, data.Height, cellSize)44+45+ pdf.SetDrawColor(60, 60, 60)46+ pdf.SetLineWidth(pdfexport.ThinGridLineMM)47+ for y := 0; y < data.Height; y++ {48+ for x := 0; x < data.Width; x++ {49+ cellX := startX + float64(x)*cellSize50+ cellY := startY + float64(y)*cellSize51+ pdf.Rect(cellX, cellY, cellSize, cellSize, "D")52+ if data.Givens[y][x] == 0 {53+ continue54+ }55+ drawFillominoGiven(pdf, cellX, cellY, cellSize, strconv.Itoa(data.Givens[y][x]))56+ }57+ }58+59+ pdf.SetDrawColor(35, 35, 35)60+ pdf.SetLineWidth(pdfexport.OuterBorderLineMM)61+ pdf.Rect(startX, startY, blockW, blockH, "D")62+63+ ruleY := pdfexport.InstructionY(startY+blockH, pageH, 1)64+ pdfexport.SetInstructionStyle(pdf)65+ pdf.SetXY(area.X, ruleY)66+ pdf.CellFormat(67+ area.W,68+ pdfexport.InstructionLineHMM,69+ "Each connected region must contain exactly the number shown in its cells.",70+ "",71+ 0,72+ "C",73+ false,74+ 0,75+ "",76+ )77+}78+79+func drawFillominoGiven(pdf *fpdf.Fpdf, x, y, cellSize float64, text string) {80+ fontSize := pdfexport.ClampStandardCellFontSize(pdfexport.StandardCellFontSize(cellSize, 0.58))81+ pdf.SetTextColor(pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray)82+ pdf.SetFont(pdfexport.SansFontFamily, "B", fontSize)83+ lineH := fontSize * 0.984+ pdf.SetXY(x, y+(cellSize-lineH)/2)85+ pdf.CellFormat(cellSize, lineH, text, "", 0, "C", false, 0, "")86+}87+88+func init() {89+ game.RegisterPrintAdapter(printAdapter{})90+}@@ -0,0 +1,21 @@
1+# Fillomino2+3+Grow orthogonally connected regions until each region contains exactly the number written in its cells.4+5+## Modes6+7+| Mode | Size | Notes |8+|------|------|-------|9+| Mini 5x5 | 5x5 | Fast intro board |10+| Easy 6x6 | 6x6 | Gentle deduction |11+| Medium 8x8 | 8x8 | Balanced board |12+| Hard 10x10 | 10x10 | Longer chains |13+| Expert 12x12 | 12x12 | Broad region planning |14+15+## CLI16+17+```bash18+puzzletea new fillomino19+puzzletea new fillomino "Medium 8x8"20+puzzletea new fillomino "Hard 10x10" --with-seed sample-seed21+```@@ -0,0 +1,394 @@
1+package fillomino2+3+import (4+ "image/color"5+ "math/rand/v2"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+)14+15+func TestValidateGridState(t *testing.T) {16+ tests := []struct {17+ name string18+ grid grid19+ solved bool20+ }{21+ {22+ name: "connected equal-number region is valid",23+ grid: grid{24+ {2, 2, 1},25+ {3, 3, 3},26+ },27+ solved: true,28+ },29+ {30+ name: "disconnected equal-number cells can coexist",31+ grid: grid{32+ {2, 2, 1},33+ {3, 0, 3},34+ },35+ solved: false,36+ },37+ {38+ name: "region size mismatch is invalid",39+ grid: grid{40+ {2, 2, 1},41+ {3, 3, 1},42+ },43+ solved: false,44+ },45+ {46+ name: "incomplete grid is not solved",47+ grid: grid{48+ {1, 0},49+ {0, 1},50+ },51+ solved: false,52+ },53+ }54+55+ for _, tt := range tests {56+ t.Run(tt.name, func(t *testing.T) {57+ got := validateGridState(tt.grid)58+ if got.solved != tt.solved {59+ t.Fatalf("solved = %v, want %v", got.solved, tt.solved)60+ }61+ })62+ }63+}64+65+func TestCountSolutions(t *testing.T) {66+ tests := []struct {67+ name string68+ givens grid69+ maxValue int70+ want int71+ }{72+ {73+ name: "unique solution puzzle",74+ givens: grid{75+ {2, 2, 1},76+ {3, 3, 3},77+ {2, 2, 1},78+ },79+ maxValue: 3,80+ want: 1,81+ },82+ {83+ name: "ambiguous puzzle",84+ givens: grid{85+ {0, 0, 0},86+ {0, 0, 0},87+ },88+ maxValue: 3,89+ want: 2,90+ },91+ {92+ name: "invalid fixed clues",93+ givens: grid{94+ {1, 1, 0},95+ {0, 0, 0},96+ },97+ maxValue: 3,98+ want: 0,99+ },100+ }101+102+ for _, tt := range tests {103+ t.Run(tt.name, func(t *testing.T) {104+ got := countSolutions(tt.givens, tt.maxValue, 2)105+ if got != tt.want {106+ t.Fatalf("countSolutions() = %d, want %d", got, tt.want)107+ }108+ })109+ }110+}111+112+func TestGeneratePuzzleSeeded(t *testing.T) {113+ mode := Modes[0].(Mode)114+ rngA := rand.New(rand.NewPCG(11, 22))115+ rngB := rand.New(rand.NewPCG(11, 22))116+117+ a, err := GeneratePuzzleSeeded(mode.Size, mode.Size, mode.MaxRegion, mode.GivenRatio, rngA)118+ if err != nil {119+ t.Fatal(err)120+ }121+ b, err := GeneratePuzzleSeeded(mode.Size, mode.Size, mode.MaxRegion, mode.GivenRatio, rngB)122+ if err != nil {123+ t.Fatal(err)124+ }125+126+ if encodeGrid(a.Givens) != encodeGrid(b.Givens) {127+ t.Fatalf("seeded givens differ:\n%s\n!=\n%s", encodeGrid(a.Givens), encodeGrid(b.Givens))128+ }129+ if validateGridState(a.Solution).solved != true {130+ t.Fatal("generated solution is not solved")131+ }132+ if got := countSolutions(a.Givens, mode.MaxRegion, 2); got != 1 {133+ t.Fatalf("generated puzzle solutions = %d, want 1", got)134+ }135+}136+137+func TestSaveRoundTrip(t *testing.T) {138+ puzzle := Puzzle{139+ Width: 3,140+ Height: 3,141+ Givens: grid{{2, 0, 1}, {0, 3, 0}, {2, 0, 1}},142+ }143+ g, err := New(NewMode("Test", "desc", 3, 3, 1), puzzle)144+ if err != nil {145+ t.Fatal(err)146+ }147+ model := g.(Model)148+ model.cursor = pointCursor(1, 0)149+ model.setCell(2)150+151+ save, err := model.GetSave()152+ if err != nil {153+ t.Fatal(err)154+ }155+ loaded, err := ImportModel(save)156+ if err != nil {157+ t.Fatal(err)158+ }159+160+ if got, want := loaded.grid[0][1], 2; got != want {161+ t.Fatalf("loaded grid[0][1] = %d, want %d", got, want)162+ }163+ if !loaded.provided[0][0] || !loaded.provided[0][2] || !loaded.provided[1][1] || !loaded.provided[2][2] {164+ t.Fatal("expected provided mask to persist")165+ }166+}167+168+func TestCellViewConflictedCursorIsDistinct(t *testing.T) {169+ conflict := cellView(3, false, false, false, false, false, false, true, nil)170+ cursorConflict := cellView(3, false, true, false, false, false, false, true, nil)171+172+ if conflict == cursorConflict {173+ t.Fatal("conflicted cursor should render differently from plain conflicted cell")174+ }175+ if !strings.Contains(cursorConflict, game.CursorLeft+"3"+game.CursorRight) {176+ t.Fatal("conflicted cursor should include cursor markers")177+ }178+}179+180+func TestDynamicGridEdges(t *testing.T) {181+ g := grid{182+ {2, 2, 1},183+ {3, 3, 1},184+ }185+ h := grid{186+ {4, 1},187+ {4, 2},188+ }189+190+ if hasVerticalEdge(g, 1, 0) {191+ t.Fatal("expected open interior edge inside a region")192+ }193+ if !hasVerticalEdge(g, 2, 0) {194+ t.Fatal("expected border between different regions")195+ }196+ if horizontalEdge(h, 0, 1) {197+ t.Fatal("expected open horizontal edge inside a region")198+ }199+ if !horizontalEdge(g, 0, 0) {200+ t.Fatal("expected top outer border")201+ }202+}203+204+func TestCompletedRegionGapBackgrounds(t *testing.T) {205+ conflicts := validateGridState(grid{{4, 4}, {4, 4}}).conflicts206+ m := Model{207+ width: 2,208+ height: 2,209+ grid: grid{{4, 4}, {4, 4}},210+ conflicts: conflicts,211+ }212+ completed := completedRegionBackgrounds(m.grid, m.conflicts)213+ regionSet := map[point]struct{}{}214+215+ if got := verticalGapBackground(m, regionSet, completed, 1, 0); got == nil {216+ t.Fatal("expected completed region to color vertical interior gap")217+ }218+ if got := horizontalGapBackground(m, regionSet, completed, 0, 1); got == nil {219+ t.Fatal("expected completed region to color horizontal interior gap")220+ }221+ if got := junctionGapBackground(m, regionSet, completed, 1, 1); got == nil {222+ t.Fatal("expected completed region to color interior junction")223+ }224+}225+226+func TestConflictGapBackgroundUsesConflictColor(t *testing.T) {227+ conflicts := validateGridState(grid{{4, 4}, {4, 4}}).conflicts228+ m := Model{229+ width: 2,230+ height: 2,231+ grid: grid{{4, 4}, {4, 4}},232+ conflicts: conflicts,233+ }234+ m.conflicts[0][0] = true235+236+ if got := verticalGapBackground(m, nil, nil, 1, 0); !sameColor(got, game.ConflictBG()) {237+ t.Fatal("expected conflict color to bridge vertical interior gap")238+ }239+ if got := junctionGapBackground(m, nil, nil, 1, 1); !sameColor(got, game.ConflictBG()) {240+ t.Fatal("expected conflict color to bridge interior junction")241+ }242+}243+244+func TestGridViewUsesUniformRows(t *testing.T) {245+ conflicts := validateGridState(grid{{2, 2, 1}, {3, 3, 3}, {1, 2, 2}}).conflicts246+ m := Model{247+ width: 3,248+ height: 3,249+ grid: grid{{2, 2, 1}, {3, 3, 3}, {1, 2, 2}},250+ provided: newProvidedMask(grid{{2, 2, 1}, {3, 3, 3}, {1, 2, 2}}),251+ conflicts: conflicts,252+ cursor: pointCursor(1, 1),253+ }254+255+ view := gridView(m)256+ lines := strings.Split(view, "\n")257+ if got, want := len(lines), m.height*2+1; got != want {258+ t.Fatalf("grid line count = %d, want %d", got, want)259+ }260+261+ wantWidth := lipgloss.Width(lines[0])262+ for i, line := range lines {263+ if width := lipgloss.Width(line); width != wantWidth {264+ t.Fatalf("line %d width = %d, want %d", i, width, wantWidth)265+ }266+ }267+}268+269+func TestSolvedGapBackgroundUsesSolvedColor(t *testing.T) {270+ conflicts := validateGridState(grid{{2, 2}}).conflicts271+ m := Model{272+ width: 2,273+ height: 1,274+ grid: grid{{2, 2}},275+ conflicts: conflicts,276+ solved: true,277+ }278+ got := verticalGapBackground(m, nil, nil, 1, 0)279+ if got == nil {280+ t.Fatal("expected solved gap background")281+ }282+ if !sameColor(got, theme.Current().SuccessBG) {283+ t.Fatal("expected solved gap background to use solved color")284+ }285+}286+287+func TestScreenToGrid(t *testing.T) {288+ m := Model{289+ width: 3,290+ height: 3,291+ grid: grid{{2, 2, 1}, {3, 3, 3}, {1, 2, 2}},292+ provided: newProvidedMask(grid{{2, 2, 1}, {3, 3, 3}, {1, 2, 2}}),293+ conflicts: validateGridState(grid{{2, 2, 1}, {3, 3, 3}, {1, 2, 2}}).conflicts,294+ keys: DefaultKeyMap,295+ modeTitle: "Test",296+ termWidth: 120,297+ termHeight: 40,298+ }299+300+ ox, oy := m.gridOrigin()301+ tests := []struct {302+ name string303+ screenX int304+ screenY int305+ wantCol int306+ wantRow int307+ wantOK bool308+ }{309+ {"origin cell", ox, oy, 0, 0, true},310+ {"cell (1,0)", ox + cellWidth + 1, oy, 1, 0, true},311+ {"cell (0,1)", ox, oy + 2, 0, 1, true},312+ {"vertical border miss", ox + cellWidth, oy, 0, 0, false},313+ {"horizontal border miss", ox, oy + 1, 0, 0, false},314+ {"outside left", ox - 1, oy, 0, 0, false},315+ {"outside bottom", ox, oy + 6, 0, 0, false},316+ }317+318+ for _, tt := range tests {319+ t.Run(tt.name, func(t *testing.T) {320+ col, row, ok := m.screenToGrid(tt.screenX, tt.screenY)321+ if ok != tt.wantOK {322+ t.Fatalf("ok = %v, want %v", ok, tt.wantOK)323+ }324+ if ok && (col != tt.wantCol || row != tt.wantRow) {325+ t.Fatalf("screenToGrid(%d, %d) = (%d, %d), want (%d, %d)", tt.screenX, tt.screenY, col, row, tt.wantCol, tt.wantRow)326+ }327+ })328+ }329+}330+331+func TestMouseClickMovesCursor(t *testing.T) {332+ puzzle := Puzzle{333+ Width: 3,334+ Height: 3,335+ Givens: grid{{2, 0, 1}, {0, 3, 0}, {2, 0, 1}},336+ }337+ g, err := New(NewMode("Test", "desc", 3, 3, 3), puzzle)338+ if err != nil {339+ t.Fatal(err)340+ }341+ m := g.(Model)342+ m.termWidth = 120343+ m.termHeight = 40344+345+ x, y := fillominoCellScreenCoords(&m, 2, 1)346+ next, _ := m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})347+ got := next.(Model)348+349+ if got.cursor.X != 2 || got.cursor.Y != 1 {350+ t.Fatalf("cursor = (%d,%d), want (2,1)", got.cursor.X, got.cursor.Y)351+ }352+}353+354+func TestMouseClickOnBorderDoesNotMoveCursor(t *testing.T) {355+ puzzle := Puzzle{356+ Width: 3,357+ Height: 3,358+ Givens: grid{{2, 0, 1}, {0, 3, 0}, {2, 0, 1}},359+ }360+ g, err := New(NewMode("Test", "desc", 3, 3, 3), puzzle)361+ if err != nil {362+ t.Fatal(err)363+ }364+ m := g.(Model)365+ m.termWidth = 120366+ m.termHeight = 40367+ m.cursor = pointCursor(1, 1)368+369+ x, y := fillominoCellScreenCoords(&m, 1, 1)370+ next, _ := m.Update(tea.MouseClickMsg{X: x + cellWidth, Y: y, Button: tea.MouseLeft})371+ got := next.(Model)372+373+ if got.cursor.X != 1 || got.cursor.Y != 1 {374+ t.Fatalf("cursor moved to (%d,%d), want unchanged (1,1)", got.cursor.X, got.cursor.Y)375+ }376+}377+378+func pointCursor(x, y int) game.Cursor {379+ return game.Cursor{X: x, Y: y}380+}381+382+func sameColor(a, b color.Color) bool {383+ if a == nil || b == nil {384+ return a == b385+ }386+ ar, ag, ab, aa := a.RGBA()387+ br, bg, bb, ba := b.RGBA()388+ return ar == br && ag == bg && ab == bb && aa == ba389+}390+391+func fillominoCellScreenCoords(m *Model, col, row int) (int, int) {392+ ox, oy := m.gridOrigin()393+ return ox + col*(cellWidth+1), oy + row*2394+}@@ -0,0 +1,214 @@
1+package fillomino2+3+import (4+ "fmt"5+ "math/rand/v2"6+)7+8+type Puzzle struct {9+ Width int10+ Height int11+ Givens grid12+ Solution grid13+}14+15+type region struct {16+ cells []point17+ size int18+}19+20+func GeneratePuzzle(width, height, maxRegion int, givenRatio float64) (Puzzle, error) {21+ rng := rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))22+ return GeneratePuzzleSeeded(width, height, maxRegion, givenRatio, rng)23+}24+25+func GeneratePuzzleSeeded(width, height, maxRegion int, givenRatio float64, rng *rand.Rand) (Puzzle, error) {26+ const maxAttempts = 1227+ for attempt := 0; attempt < maxAttempts; attempt++ {28+ solution, _, err := generateSolution(width, height, maxRegion, rng)29+ if err != nil {30+ continue31+ }32+33+ givens := buildStartingGivens(solution)34+ givens = removeClues(givens, maxRegion, givenRatio, rng)35+ if countSolutions(givens, maxRegion, 2) != 1 {36+ continue37+ }38+39+ return Puzzle{40+ Width: width,41+ Height: height,42+ Givens: givens,43+ Solution: solution,44+ }, nil45+ }46+47+ return Puzzle{}, fmt.Errorf("generate fillomino puzzle %dx%d: exceeded retry budget", width, height)48+}49+50+func generateSolution(width, height, maxRegion int, rng *rand.Rand) (grid, []region, error) {51+ assigned := newGrid(width, height)52+ used := make([][]bool, height)53+ for y := range height {54+ used[y] = make([]bool, width)55+ }56+57+ regions := make([]region, 0, width*height/2)58+ if !fillRegions(assigned, used, ®ions, maxRegion, rng) {59+ return nil, nil, fmt.Errorf("unable to fill solved grid")60+ }61+ return assigned, regions, nil62+}63+64+func fillRegions(solution grid, used [][]bool, regions *[]region, maxRegion int, rng *rand.Rand) bool {65+ start, ok := firstUnused(used)66+ if !ok {67+ return true68+ }69+70+ candidates := rectangleCandidates(used, start, maxRegion, rng)71+ for _, candidate := range candidates {72+ if touchesEqualRegion(solution, candidate.cells, candidate.size) {73+ continue74+ }75+76+ applyShape(solution, used, candidate.cells, candidate.size, true)77+ *regions = append(*regions, candidate)78+ if fillRegions(solution, used, regions, maxRegion, rng) {79+ return true80+ }81+ *regions = (*regions)[:len(*regions)-1]82+ applyShape(solution, used, candidate.cells, candidate.size, false)83+ }84+85+ return false86+}87+88+func firstUnused(used [][]bool) (point, bool) {89+ for y := range len(used) {90+ for x := range len(used[y]) {91+ if !used[y][x] {92+ return point{x: x, y: y}, true93+ }94+ }95+ }96+ return point{}, false97+}98+99+func remainingUnused(used [][]bool) int {100+ count := 0101+ for y := range len(used) {102+ for x := range len(used[y]) {103+ if !used[y][x] {104+ count++105+ }106+ }107+ }108+ return count109+}110+111+func rectangleCandidates(used [][]bool, start point, maxRegion int, rng *rand.Rand) []region {112+ height := len(used)113+ width := len(used[0])114+115+ var candidates []region116+ for h := 1; start.y+h <= height; h++ {117+ for w := 1; start.x+w <= width; w++ {118+ size := w * h119+ if size > maxRegion {120+ break121+ }122+ cells := make([]point, 0, size)123+ valid := true124+ for y := start.y; y < start.y+h && valid; y++ {125+ for x := start.x; x < start.x+w; x++ {126+ if used[y][x] {127+ valid = false128+ break129+ }130+ cells = append(cells, point{x: x, y: y})131+ }132+ }133+ if !valid {134+ continue135+ }136+ candidates = append(candidates, region{cells: cells, size: size})137+ }138+ }139+ rng.Shuffle(len(candidates), func(i, j int) {140+ candidates[i], candidates[j] = candidates[j], candidates[i]141+ })142+ return candidates143+}144+145+func touchesEqualRegion(solution grid, shape []point, size int) bool {146+ shapeCells := make(map[point]struct{}, len(shape))147+ for _, cell := range shape {148+ shapeCells[cell] = struct{}{}149+ }150+151+ for _, cell := range shape {152+ for _, next := range orthogonalNeighbors(cell, len(solution[0]), len(solution)) {153+ if _, ok := shapeCells[next]; ok {154+ continue155+ }156+ if solution[next.y][next.x] == size {157+ return true158+ }159+ }160+ }161+162+ return false163+}164+165+func applyShape(solution grid, used [][]bool, shape []point, size int, place bool) {166+ value := 0167+ if place {168+ value = size169+ }170+ for _, cell := range shape {171+ used[cell.y][cell.x] = place172+ solution[cell.y][cell.x] = value173+ }174+}175+176+func buildStartingGivens(solution grid) grid {177+ return cloneGrid(solution)178+}179+180+func removeClues(givens grid, maxRegion int, givenRatio float64, rng *rand.Rand) grid {181+ width := len(givens[0])182+ height := len(givens)183+ target := int(float64(width*height) * givenRatio)184+ if target < 1 {185+ target = 1186+ }187+188+ cells := make([]point, 0, width*height)189+ for y := range height {190+ for x := range width {191+ if givens[y][x] != 0 {192+ cells = append(cells, point{x: x, y: y})193+ }194+ }195+ }196+ rng.Shuffle(len(cells), func(i, j int) {197+ cells[i], cells[j] = cells[j], cells[i]198+ })199+200+ remaining := len(cells)201+ for _, cell := range cells {202+ if remaining <= target {203+ break204+ }205+ value := givens[cell.y][cell.x]206+ givens[cell.y][cell.x] = 0207+ if countSolutions(givens, maxRegion, 2) != 1 {208+ givens[cell.y][cell.x] = value209+ continue210+ }211+ remaining--212+ }213+ return givens214+}@@ -0,0 +1,151 @@
1+package fillomino2+3+import (4+ "strconv"5+ "strings"6+)7+8+type point struct {9+ x int10+ y int11+}12+13+type grid [][]int14+15+func newGrid(width, height int) grid {16+ cells := make(grid, height)17+ for y := range height {18+ cells[y] = make([]int, width)19+ }20+ return cells21+}22+23+func cloneGrid(src grid) grid {24+ if src == nil {25+ return nil26+ }27+28+ height := len(src)29+ width := 030+ if height > 0 {31+ width = len(src[0])32+ }33+34+ dst := newGrid(width, height)35+ for y := range height {36+ copy(dst[y], src[y])37+ }38+ return dst39+}40+41+func parseGrid(encoded string, width, height int) (grid, error) {42+ cells := newGrid(width, height)43+ rows := strings.Split(strings.TrimSpace(encoded), "\n")44+ for y := range height {45+ if y >= len(rows) {46+ continue47+ }48+49+ fields := strings.Fields(rows[y])50+ if len(fields) == 0 {51+ fields = splitCompactRow(rows[y])52+ }53+ if len(fields) != width {54+ return nil, errInvalidGridEncoding55+ }56+ for x := range width {57+ if fields[x] == "." {58+ continue59+ }60+ value, err := strconv.Atoi(fields[x])61+ if err != nil {62+ return nil, errInvalidGridEncoding63+ }64+ cells[y][x] = value65+ }66+ }67+ return cells, nil68+}69+70+func splitCompactRow(row string) []string {71+ items := make([]string, 0, len(row))72+ for _, r := range row {73+ items = append(items, string(r))74+ }75+ return items76+}77+78+func encodeGrid(g grid) string {79+ var rows []string80+ for y, row := range g {81+ items := make([]string, len(row))82+ for x, value := range row {83+ if value == 0 {84+ items[x] = "."85+ continue86+ }87+ items[x] = strconv.Itoa(value)88+ }89+ rows = append(rows, strings.Join(items, " "))90+ if y == len(g)-1 {91+ break92+ }93+ }94+ return strings.Join(rows, "\n")95+}96+97+func newProvidedMask(givens grid) [][]bool {98+ mask := make([][]bool, len(givens))99+ for y := range len(givens) {100+ mask[y] = make([]bool, len(givens[y]))101+ for x := range len(givens[y]) {102+ mask[y][x] = givens[y][x] != 0103+ }104+ }105+ return mask106+}107+108+func serializeProvided(mask [][]bool) string {109+ var rows []string110+ for _, row := range mask {111+ var line strings.Builder112+ for _, value := range row {113+ if value {114+ line.WriteByte('#')115+ continue116+ }117+ line.WriteByte('.')118+ }119+ rows = append(rows, line.String())120+ }121+ return strings.Join(rows, "\n")122+}123+124+func deserializeProvided(encoded string, width, height int) [][]bool {125+ mask := make([][]bool, height)126+ rows := strings.Split(encoded, "\n")127+ for y := range height {128+ mask[y] = make([]bool, width)129+ if y >= len(rows) {130+ continue131+ }132+ for x := range width {133+ if x < len(rows[y]) && rows[y][x] == '#' {134+ mask[y][x] = true135+ }136+ }137+ }138+ return mask139+}140+141+func maxCellValue(g grid) int {142+ best := 0143+ for y := range len(g) {144+ for x := range len(g[y]) {145+ if g[y][x] > best {146+ best = g[y][x]147+ }148+ }149+ }150+ return best151+}@@ -0,0 +1,27 @@
1+# Fillomino2+3+Fill the board so that each orthogonally connected region contains exactly as many cells as its number.4+5+## Rules6+7+- Every numbered region must be orthogonally connected.8+- The size of each region must equal the number written in its cells.9+- Regions with the same number may exist multiple times, but they cannot touch orthogonally.10+- Given cells are locked. The puzzle is solved when every cell is filled and every region is valid.11+12+## Controls13+14+| Key | Action |15+|-----|--------|16+| `Arrows` / `wasd` / `hjkl` | Move cursor |17+| `1`-`9` | Place number |18+| `Backspace` / `Delete` | Clear cell |19+| `Ctrl+H` | Toggle help bar |20+| `Ctrl+R` | Reset puzzle |21+| `Ctrl+N` | Return to main menu |22+23+## Tips24+25+- Start from givens that force a small region, like `1`, `2`, or `3`.26+- If a connected group already has its full size, every orthogonal neighbour must belong to another region.27+- If a group cannot possibly grow to its required size, something inside or next to it is wrong.@@ -0,0 +1,37 @@
1+package fillomino2+3+import (4+ "charm.land/bubbles/v2/key"5+ "github.com/FelineStateMachine/puzzletea/game"6+)7+8+type KeyMap struct {9+ game.CursorKeyMap10+ Clear key.Binding11+}12+13+var DefaultKeyMap = KeyMap{14+ CursorKeyMap: game.DefaultCursorKeyMap,15+ Clear: key.NewBinding(16+ key.WithKeys("backspace", "delete"),17+ key.WithHelp("bkspc", "Clear"),18+ ),19+}20+21+func (m *Model) updateKeyBindings() {22+ m.keys.Up.SetEnabled(m.cursor.Y > 0)23+ m.keys.Down.SetEnabled(m.cursor.Y < m.height-1)24+ m.keys.Left.SetEnabled(m.cursor.X > 0)25+ m.keys.Right.SetEnabled(m.cursor.X < m.width-1)26+27+ locked := m.provided[m.cursor.Y][m.cursor.X]28+ enabled := !locked && !m.solved29+ m.keys.Clear.SetEnabled(enabled)30+}31+32+func (m Model) GetFullHelp() [][]key.Binding {33+ return [][]key.Binding{34+ {m.keys.Up, m.keys.Down, m.keys.Left, m.keys.Right},35+ {m.keys.Clear},36+ }37+}@@ -0,0 +1,82 @@
1+package fillomino2+3+import (4+ "strings"5+6+ "charm.land/lipgloss/v2"7+ "github.com/FelineStateMachine/puzzletea/game"8+)9+10+// screenToGrid converts terminal coordinates to fillomino grid coordinates.11+// Returns false when the click lands on a border row/column or outside the grid.12+func (m *Model) screenToGrid(screenX, screenY int) (col, row int, ok bool) {13+ ox, oy := m.cachedGridOrigin()14+ lx := screenX - ox15+ ly := screenY - oy16+ if lx < 0 || ly < 0 {17+ return 0, 0, false18+ }19+20+ maxX := 021+ if m.width > 0 {22+ maxX = (m.width-1)*(cellWidth+1) + (cellWidth - 1)23+ }24+ maxY := 025+ if m.height > 0 {26+ maxY = (m.height - 1) * 227+ }28+ if lx > maxX || ly > maxY {29+ return 0, 0, false30+ }31+32+ col = lx / (cellWidth + 1)33+ row = ly / 234+ if lx%(cellWidth+1) >= cellWidth || ly%2 != 0 {35+ return 0, 0, false36+ }37+ if col < 0 || col >= m.width || row < 0 || row >= m.height {38+ return 0, 0, false39+ }40+41+ return col, row, true42+}43+44+func (m *Model) cachedGridOrigin() (x, y int) {45+ if m.originValid {46+ return m.originX, m.originY47+ }48+ x, y = m.gridOrigin()49+ m.originX, m.originY = x, y50+ m.originValid = true51+ return x, y52+}53+54+func (m *Model) gridOrigin() (x, y int) {55+ gameView := m.View()56+ viewWidth := lipgloss.Width(gameView)57+ viewHeight := lipgloss.Height(gameView)58+59+ if viewWidth > m.termWidth {60+ viewWidth = m.termWidth61+ }62+ if viewHeight > m.termHeight {63+ viewHeight = m.termHeight64+ }65+66+ centerX := max((m.termWidth-viewWidth)/2, 0)67+ centerY := max((m.termHeight-viewHeight)/2, 0)68+69+ title := game.TitleBarView("Fillomino", m.modeTitle, m.solved)70+ titleHeight := strings.Count(title, "\n") + 171+72+ grid := gridView(*m)73+ gridWidth := lipgloss.Width(grid)74+ gridPadLeft := max((viewWidth-gridWidth)/2, 0)75+76+ const borderLeft = 177+ const borderTop = 178+79+ x = centerX + gridPadLeft + borderLeft80+ y = centerY + titleHeight + borderTop81+ return x, y82+}@@ -0,0 +1,276 @@
1+package fillomino2+3+import (4+ "errors"5+ "math"6+ "slices"7+)8+9+var errInvalidGridEncoding = errors.New("invalid fillomino grid encoding")10+11+type component struct {12+ value int13+ cells []point14+ frontier map[point]struct{}15+}16+17+type validationResult struct {18+ solved bool19+ conflicts [][]bool20+}21+22+func validateGridState(g grid) validationResult {23+ height := len(g)24+ width := 025+ if height > 0 {26+ width = len(g[0])27+ }28+29+ conflicts := make([][]bool, height)30+ for y := range height {31+ conflicts[y] = make([]bool, width)32+ }33+34+ visited := make([][]bool, height)35+ for y := range height {36+ visited[y] = make([]bool, width)37+ }38+39+ complete := true40+ for y := range height {41+ for x := range width {42+ if g[y][x] == 0 {43+ complete = false44+ continue45+ }46+ if visited[y][x] {47+ continue48+ }49+50+ comp := buildComponent(g, point{x: x, y: y}, visited)51+ markComponentConflicts(g, comp, complete, conflicts)52+ }53+ }54+55+ solved := complete && !hasConflicts(conflicts)56+ return validationResult{57+ solved: solved,58+ conflicts: conflicts,59+ }60+}61+62+func hasConflicts(conflicts [][]bool) bool {63+ for y := range len(conflicts) {64+ for x := range len(conflicts[y]) {65+ if conflicts[y][x] {66+ return true67+ }68+ }69+ }70+ return false71+}72+73+func buildComponent(g grid, start point, visited [][]bool) component {74+ value := g[start.y][start.x]75+ queue := []point{start}76+ visited[start.y][start.x] = true77+ comp := component{78+ value: value,79+ frontier: make(map[point]struct{}),80+ }81+82+ for len(queue) > 0 {83+ curr := queue[0]84+ queue = queue[1:]85+ comp.cells = append(comp.cells, curr)86+87+ for _, next := range orthogonalNeighbors(curr, len(g[0]), len(g)) {88+ switch {89+ case g[next.y][next.x] == value && !visited[next.y][next.x]:90+ visited[next.y][next.x] = true91+ queue = append(queue, next)92+ case g[next.y][next.x] == 0:93+ comp.frontier[next] = struct{}{}94+ }95+ }96+ }97+98+ return comp99+}100+101+func markComponentConflicts(g grid, comp component, assumeComplete bool, conflicts [][]bool) {102+ size := len(comp.cells)103+ value := comp.value104+ if value <= 0 {105+ for _, cell := range comp.cells {106+ conflicts[cell.y][cell.x] = true107+ }108+ return109+ }110+111+ if size > value || size+len(comp.frontier) < value {112+ for _, cell := range comp.cells {113+ conflicts[cell.y][cell.x] = true114+ }115+ for cell := range comp.frontier {116+ conflicts[cell.y][cell.x] = true117+ }118+ return119+ }120+121+ if assumeComplete && size != value {122+ for _, cell := range comp.cells {123+ conflicts[cell.y][cell.x] = true124+ }125+ }126+}127+128+func orthogonalNeighbors(p point, width, height int) []point {129+ neighbors := make([]point, 0, 4)130+ if p.x > 0 {131+ neighbors = append(neighbors, point{x: p.x - 1, y: p.y})132+ }133+ if p.x+1 < width {134+ neighbors = append(neighbors, point{x: p.x + 1, y: p.y})135+ }136+ if p.y > 0 {137+ neighbors = append(neighbors, point{x: p.x, y: p.y - 1})138+ }139+ if p.y+1 < height {140+ neighbors = append(neighbors, point{x: p.x, y: p.y + 1})141+ }142+ return neighbors143+}144+145+func countSolutions(givens grid, maxValue, limit int) int {146+ working := cloneGrid(givens)147+ return searchSolutions(working, maxValue, limit)148+}149+150+func searchSolutions(g grid, maxValue, limit int) int {151+ if limit <= 0 {152+ return 0153+ }154+ if !isPartialGridValid(g) {155+ return 0156+ }157+158+ cell, candidates, ok := chooseNextCell(g, maxValue)159+ if !ok {160+ if validateGridState(g).solved {161+ return 1162+ }163+ return 0164+ }165+166+ total := 0167+ for _, value := range candidates {168+ g[cell.y][cell.x] = value169+ total += searchSolutions(g, maxValue, limit-total)170+ if total >= limit {171+ g[cell.y][cell.x] = 0172+ return total173+ }174+ g[cell.y][cell.x] = 0175+ }176+ return total177+}178+179+func chooseNextCell(g grid, maxValue int) (point, []int, bool) {180+ best := point{}181+ bestCandidates := []int(nil)182+ bestCount := math.MaxInt183+184+ for y := range len(g) {185+ for x := range len(g[y]) {186+ if g[y][x] != 0 {187+ continue188+ }189+190+ candidates := candidatesForCell(g, point{x: x, y: y}, maxValue)191+ if len(candidates) == 0 {192+ return point{x: x, y: y}, nil, true193+ }194+ if len(candidates) < bestCount {195+ best = point{x: x, y: y}196+ bestCandidates = candidates197+ bestCount = len(candidates)198+ if bestCount == 1 {199+ return best, bestCandidates, true200+ }201+ }202+ }203+ }204+205+ if bestCount == math.MaxInt {206+ return point{}, nil, false207+ }208+ return best, bestCandidates, true209+}210+211+func candidatesForCell(g grid, cell point, maxValue int) []int {212+ candidates := make([]int, 0, maxValue)213+ for value := 1; value <= maxValue; value++ {214+ g[cell.y][cell.x] = value215+ if isPartialGridValid(g) {216+ candidates = append(candidates, value)217+ }218+ g[cell.y][cell.x] = 0219+ }220+ return candidates221+}222+223+func isPartialGridValid(g grid) bool {224+ height := len(g)225+ if height == 0 {226+ return true227+ }228+ width := len(g[0])229+230+ visited := make([][]bool, height)231+ for y := range height {232+ visited[y] = make([]bool, width)233+ }234+235+ for y := range height {236+ for x := range width {237+ if g[y][x] == 0 || visited[y][x] {238+ continue239+ }240+241+ comp := buildComponent(g, point{x: x, y: y}, visited)242+ size := len(comp.cells)243+ if size > comp.value {244+ return false245+ }246+ if size+len(comp.frontier) < comp.value {247+ return false248+ }249+ if len(comp.frontier) == 0 && size != comp.value {250+ return false251+ }252+ }253+ }254+255+ return true256+}257+258+func regionCellsAt(g grid, start point) []point {259+ value := g[start.y][start.x]260+ if value == 0 {261+ return nil262+ }263+264+ visited := make([][]bool, len(g))265+ for y := range len(g) {266+ visited[y] = make([]bool, len(g[y]))267+ }268+ comp := buildComponent(g, start, visited)269+ slices.SortFunc(comp.cells, func(a, b point) int {270+ if a.y != b.y {271+ return a.y - b.y272+ }273+ return a.x - b.x274+ })275+ return comp.cells276+}@@ -0,0 +1,372 @@
1+package fillomino2+3+import (4+ "image/color"5+ "strconv"6+ "strings"7+8+ "charm.land/lipgloss/v2"9+ "github.com/FelineStateMachine/puzzletea/game"10+ "github.com/FelineStateMachine/puzzletea/theme"11+)12+13+const cellWidth = 314+15+func cellBaseStyle() lipgloss.Style {16+ p := theme.Current()17+ return lipgloss.NewStyle().18+ Foreground(p.FG).19+ Background(p.BG)20+}21+22+func emptyCellStyle() lipgloss.Style {23+ p := theme.Current()24+ return lipgloss.NewStyle().25+ Foreground(p.TextDim).26+ Background(p.BG)27+}28+29+func cellView(30+ value int,31+ provided, cursor, rowHighlight, colHighlight, regionHighlight, solved, conflict bool,32+ completedBG color.Color,33+) string {34+ p := theme.Current()35+ style := cellBaseStyle()36+ text := " · "37+ if value == 0 {38+ style = emptyCellStyle()39+ } else {40+ text = lipgloss.NewStyle().Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(strconv.Itoa(value))41+ }42+43+ if provided && value != 0 {44+ style = style.Bold(true)45+ }46+ if conflict && cursor {47+ style = conflictedCursorStyle()48+ } else if conflict {49+ style = style.Foreground(game.ConflictFG()).Background(game.ConflictBG())50+ } else if solved {51+ style = style.Foreground(game.SolvedFG()).Background(p.SuccessBG)52+ } else if cursor {53+ style = game.CursorStyle()54+ } else if completedBG != nil {55+ style = style.Background(completedBG).Foreground(theme.TextOnBG(completedBG))56+ } else if regionHighlight {57+ style = style.Background(p.HighlightBG)58+ } else if rowHighlight || colHighlight {59+ style = style.Background(p.Surface)60+ }61+62+ if cursor {63+ if value == 0 {64+ text = game.CursorLeft + "·" + game.CursorRight65+ } else {66+ text = game.CursorLeft + strconv.Itoa(value) + game.CursorRight67+ }68+ }69+70+ return style.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(text)71+}72+73+func conflictedCursorStyle() lipgloss.Style {74+ return lipgloss.NewStyle().75+ Bold(true).76+ Foreground(game.CursorFG()).77+ Background(game.ConflictBG())78+}79+80+func gridView(m Model) string {81+ highlight := regionCellsAt(m.grid, point{x: m.cursor.X, y: m.cursor.Y})82+ completed := completedRegionBackgrounds(m.grid, m.conflicts)83+ regionSet := make(map[point]struct{}, len(highlight))84+ for _, cell := range highlight {85+ regionSet[cell] = struct{}{}86+ }87+88+ rows := make([]string, 0, m.height*2+1)89+ rows = append(rows, boundaryRow(m, 0, regionSet, completed))90+ for y := range m.height {91+ rows = append(rows, contentRow(m, y, regionSet, completed))92+ rows = append(rows, boundaryRow(m, y+1, regionSet, completed))93+ }94+ return lipgloss.JoinVertical(lipgloss.Left, rows...)95+}96+97+func contentRow(m Model, y int, regionSet map[point]struct{}, completed map[point]color.Color) string {98+ var b strings.Builder99+ for x := range m.width {100+ b.WriteString(renderBorderChar(verticalEdge(m.grid, x, y), m.solved, verticalGapBackground(m, regionSet, completed, x, y)))101+ _, inRegion := regionSet[point{x: x, y: y}]102+ b.WriteString(cellView(103+ m.grid[y][x],104+ m.provided[y][x],105+ x == m.cursor.X && y == m.cursor.Y,106+ y == m.cursor.Y,107+ x == m.cursor.X,108+ inRegion,109+ m.solved,110+ m.conflicts[y][x],111+ completed[point{x: x, y: y}],112+ ))113+ }114+ b.WriteString(renderBorderChar(verticalEdge(m.grid, m.width, y), m.solved, verticalGapBackground(m, regionSet, completed, m.width, y)))115+ return b.String()116+}117+118+func boundaryRow(m Model, y int, regionSet map[point]struct{}, completed map[point]color.Color) string {119+ var b strings.Builder120+ for x := 0; x <= m.width; x++ {121+ b.WriteString(renderBorderChar(junctionRune(m.grid, x, y), m.solved, junctionGapBackground(m, regionSet, completed, x, y)))122+ if x == m.width {123+ continue124+ }125+126+ ch := ' '127+ if horizontalEdge(m.grid, x, y) {128+ ch = '─'129+ }130+ b.WriteString(renderBorderSegment(ch, m.solved, horizontalGapBackground(m, regionSet, completed, x, y)))131+ }132+ return b.String()133+}134+135+func renderBorderChar(ch rune, solved bool, bg color.Color) string {136+ p := theme.Current()137+ fg := p.Border138+ if solved {139+ fg = p.SuccessBorder140+ }141+ if bg == nil {142+ bg = p.BG143+ if solved {144+ bg = p.SuccessBG145+ }146+ }147+ return lipgloss.NewStyle().148+ Foreground(fg).149+ Background(bg).150+ Render(string(ch))151+}152+153+func renderBorderSegment(ch rune, solved bool, bg color.Color) string {154+ return strings.Repeat(renderBorderChar(ch, solved, bg), cellWidth)155+}156+157+func horizontalEdge(g grid, x, y int) bool {158+ switch {159+ case y <= 0, y >= len(g):160+ return true161+ default:162+ top := point{x: x, y: y - 1}163+ bottom := point{x: x, y: y}164+ return !sameRegion(g, top, bottom)165+ }166+}167+168+func verticalEdge(g grid, x, y int) rune {169+ if hasVerticalEdge(g, x, y) {170+ return '│'171+ }172+ return ' '173+}174+175+func hasVerticalEdge(g grid, x, y int) bool {176+ width := len(g[0])177+ switch {178+ case x <= 0, x >= width:179+ return true180+ default:181+ left := point{x: x - 1, y: y}182+ right := point{x: x, y: y}183+ return !sameRegion(g, left, right)184+ }185+}186+187+func junctionRune(g grid, x, y int) rune {188+ height := len(g)189+ width := len(g[0])190+ north := y > 0 && hasVerticalEdge(g, x, y-1)191+ south := y < height && hasVerticalEdge(g, x, y)192+ west := x > 0 && horizontalEdge(g, x-1, y)193+ east := x < width && horizontalEdge(g, x, y)194+195+ switch {196+ case north && south && west && east:197+ return '┼'198+ case north && south && west:199+ return '┤'200+ case north && south && east:201+ return '├'202+ case west && east && north:203+ return '┴'204+ case west && east && south:205+ return '┬'206+ case south && east:207+ return '┌'208+ case south && west:209+ return '┐'210+ case north && east:211+ return '└'212+ case north && west:213+ return '┘'214+ case north || south:215+ return '│'216+ case west || east:217+ return '─'218+ default:219+ return ' '220+ }221+}222+223+func verticalGapBackground(m Model, regionSet map[point]struct{}, completed map[point]color.Color, x, y int) color.Color {224+ if hasVerticalEdge(m.grid, x, y) || x <= 0 || x >= m.width {225+ return nil226+ }227+ left := point{x: x - 1, y: y}228+ right := point{x: x, y: y}229+ return gapBackground(m, regionSet, completed, left, right)230+}231+232+func horizontalGapBackground(m Model, regionSet map[point]struct{}, completed map[point]color.Color, x, y int) color.Color {233+ if horizontalEdge(m.grid, x, y) || y <= 0 || y >= m.height {234+ return nil235+ }236+ top := point{x: x, y: y - 1}237+ bottom := point{x: x, y: y}238+ return gapBackground(m, regionSet, completed, top, bottom)239+}240+241+func junctionGapBackground(m Model, regionSet map[point]struct{}, completed map[point]color.Color, x, y int) color.Color {242+ if junctionRune(m.grid, x, y) != ' ' {243+ return nil244+ }245+246+ cells := make([]point, 0, 4)247+ if x > 0 && y > 0 {248+ cells = append(cells, point{x: x - 1, y: y - 1})249+ }250+ if x < m.width && y > 0 {251+ cells = append(cells, point{x: x, y: y - 1})252+ }253+ if x > 0 && y < m.height {254+ cells = append(cells, point{x: x - 1, y: y})255+ }256+ if x < m.width && y < m.height {257+ cells = append(cells, point{x: x, y: y})258+ }259+ if len(cells) != 4 {260+ return nil261+ }262+ for i := 1; i < len(cells); i++ {263+ if !sameRegion(m.grid, cells[0], cells[i]) {264+ return nil265+ }266+ }267+ return gapBackground(m, regionSet, completed, cells...)268+}269+270+func gapBackground(m Model, regionSet map[point]struct{}, completed map[point]color.Color, cells ...point) color.Color {271+ p := theme.Current()272+ if len(cells) == 0 {273+ return nil274+ }275+ if anyCellConflict(m.conflicts, cells...) {276+ return game.ConflictBG()277+ }278+ if m.solved {279+ return p.SuccessBG280+ }281+ if bg := completed[cells[0]]; bg != nil {282+ for _, cell := range cells[1:] {283+ if completed[cell] == nil {284+ return nil285+ }286+ }287+ return bg288+ }289+ for _, cell := range cells {290+ if _, ok := regionSet[cell]; !ok {291+ return nil292+ }293+ }294+ if len(regionSet) == 0 {295+ return nil296+ }297+ return p.HighlightBG298+}299+300+func anyCellConflict(conflicts [][]bool, cells ...point) bool {301+ for _, cell := range cells {302+ if conflicts[cell.y][cell.x] {303+ return true304+ }305+ }306+ return false307+}308+309+func sameRegion(g grid, a, b point) bool {310+ return g[a.y][a.x] != 0 && g[a.y][a.x] == g[b.y][b.x]311+}312+313+func completedRegionBackgrounds(g grid, conflicts [][]bool) map[point]color.Color {314+ height := len(g)315+ if height == 0 {316+ return nil317+ }318+ width := len(g[0])319+ visited := make([][]bool, height)320+ for y := range height {321+ visited[y] = make([]bool, width)322+ }323+324+ palette := theme.Current()325+ colors := palette.ThemeColors()326+ if len(colors) == 0 {327+ return nil328+ }329+330+ backgrounds := make(map[point]color.Color)331+ for y := range height {332+ for x := range width {333+ if g[y][x] == 0 || visited[y][x] {334+ continue335+ }336+337+ comp := buildComponent(g, point{x: x, y: y}, visited)338+ if len(comp.cells) != comp.value || componentHasConflict(comp, conflicts) {339+ continue340+ }341+342+ bg := completedRegionColor(comp, colors, palette.Surface)343+ for _, cell := range comp.cells {344+ backgrounds[cell] = bg345+ }346+ }347+ }348+349+ return backgrounds350+}351+352+func componentHasConflict(comp component, conflicts [][]bool) bool {353+ for _, cell := range comp.cells {354+ if conflicts[cell.y][cell.x] {355+ return true356+ }357+ }358+ return false359+}360+361+func completedRegionColor(comp component, colors []color.Color, base color.Color) color.Color {362+ anchor := comp.cells[0]363+ index := (anchor.y*37 + anchor.x*17 + comp.value*13) % len(colors)364+ return theme.Blend(base, colors[index], 0.52)365+}366+367+func statusBarView(showFullHelp bool) string {368+ if showFullHelp {369+ return game.StatusBarStyle().Render("1-9: place bkspc: clear arrows/wasd: move ctrl+n: menu ctrl+r: reset ctrl+h: help")370+ }371+ return game.StatusBarStyle().Render("1-9: place bkspc: clear")372+}@@ -3,6 +3,8 @@ package hashiwokakero
33 import (44 "encoding/json"55 "testing"6+7+ "charm.land/lipgloss/v2"68 )79 810 // --- helpers ---@@ -340,6 +342,23 @@ func TestWouldCross(t *testing.T) {
340342 })341343 }342344 345+func TestViewHeightStableAcrossStatusModes(t *testing.T) {346+ selected := 0347+ views := []Model{348+ {puzzle: *fourCornerPuzzle(), cursorIsland: 0, keys: DefaultKeyMap, modeTitle: "Test"},349+ {puzzle: *fourCornerPuzzle(), cursorIsland: 0, keys: DefaultKeyMap, modeTitle: "Test", showFullHelp: true},350+ {puzzle: *fourCornerPuzzle(), cursorIsland: 0, keys: DefaultKeyMap, modeTitle: "Test", selectedIsland: &selected},351+ {puzzle: *fourCornerPuzzle(), cursorIsland: 0, keys: DefaultKeyMap, modeTitle: "Test", selectedIsland: &selected, showFullHelp: true},352+ }353+354+ wantHeight := lipgloss.Height(views[0].View())355+ for i, m := range views[1:] {356+ if got := lipgloss.Height(m.View()); got != wantHeight {357+ t.Fatalf("view %d height = %d, want %d", i+1, got, wantHeight)358+ }359+ }360+}361+343362 // --- IsConnected (P0) ---344363 345364 func TestIsConnected(t *testing.T) {@@ -76,7 +76,8 @@ func gridBorderSolvedStyle() lipgloss.Style {
7676 return lipgloss.NewStyle().7777 Border(lipgloss.RoundedBorder()).7878 Padding(1).79- BorderForeground(theme.Current().SuccessBorder)79+ BorderForeground(theme.Current().SuccessBorder).80+ BorderBackground(theme.Current().SuccessBG)8081 }8182 8283 // islandSelectedStyle returns the style for a selected island, using the@@ -299,6 +300,15 @@ func statusBarView(selected, showFullHelp bool) string {
299300 return game.StatusBarStyle().Render("arrows/wasd: move enter/space: select island")300301 }301302 303+func statusBarVariants() []string {304+ return []string{305+ statusBarView(false, false),306+ statusBarView(false, true),307+ statusBarView(true, false),308+ statusBarView(true, true),309+ }310+}311+302312 func infoView(p *Puzzle) string {303313 pal := theme.Current()304314 @@ -19,7 +19,7 @@ func (m Model) GetSave() ([]byte, error) {
1919 save := Save{2020 Size: m.size,2121 Numbers: m.numbers.String(),22- Marks: serializeMarks(m.marks),22+ Marks: serializeMarks(m.userMarks),2323 ModeTitle: m.modeTitle,2424 }2525 data, err := json.Marshal(save)@@ -43,15 +43,14 @@ func ImportModel(data []byte) (*Model, error) {
4343 marks := deserializeMarks(s.Marks, s.Size)4444 4545 m := &Model{46- size: s.Size,47- numbers: numbers,48- marks: marks,49- cursor: game.Cursor{X: 0, Y: 0},50- keys: DefaultKeyMap,51- modeTitle: s.ModeTitle,46+ size: s.Size,47+ numbers: numbers,48+ userMarks: marks,49+ initialUserMarks: newMarks(s.Size),50+ cursor: game.Cursor{X: 0, Y: 0},51+ keys: DefaultKeyMap,52+ modeTitle: s.ModeTitle,5253 }53- m.initialMarks = newMarks(s.Size)54- m.solved = m.checkSolved()55- m.conflicts = computeConflicts(m.numbers, m.marks, m.size)54+ m.recomputeState()5655 return m, nil5756 }@@ -12,16 +12,17 @@ import (
1212 1313 // Model implements game.Gamer for Hitori.1414 type Model struct {15- size int16- numbers grid17- marks [][]cellMark18- initialMarks [][]cellMark19- conflicts [][]bool20- cursor game.Cursor21- solved bool22- keys KeyMap23- modeTitle string24- showFullHelp bool15+ size int16+ numbers grid17+ userMarks [][]cellMark18+ marks [][]cellMark19+ initialUserMarks [][]cellMark20+ conflicts [][]bool21+ cursor game.Cursor22+ solved bool23+ keys KeyMap24+ modeTitle string25+ showFullHelp bool2526 }2627 2728 var _ game.Gamer = Model{}@@ -40,18 +41,17 @@ func New(mode HitoriMode, numbers grid) (game.Gamer, error) {
4041 }4142 }4243 43- marks := newMarks(mode.Size)44+ userMarks := newMarks(mode.Size)4445 m := Model{45- size: mode.Size,46- numbers: numbers,47- marks: marks,48- initialMarks: cloneMarks(marks),49- cursor: game.Cursor{X: 0, Y: 0},50- keys: DefaultKeyMap,51- modeTitle: mode.Title(),46+ size: mode.Size,47+ numbers: numbers,48+ userMarks: userMarks,49+ initialUserMarks: cloneMarks(userMarks),50+ cursor: game.Cursor{X: 0, Y: 0},51+ keys: DefaultKeyMap,52+ modeTitle: mode.Title(),5253 }53- m.solved = m.checkSolved()54- m.conflicts = computeConflicts(m.numbers, m.marks, m.size)54+ m.recomputeState()5555 return m, nil5656 }5757 @@ -75,9 +75,8 @@ func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {
7575 }7676 case key.Matches(msg, m.keys.ClearCell):7777 if !m.solved {78- m.marks[m.cursor.Y][m.cursor.X] = unmarked79- m.solved = m.checkSolved()80- m.conflicts = computeConflicts(m.numbers, m.marks, m.size)78+ m.userMarks[m.cursor.Y][m.cursor.X] = unmarked79+ m.recomputeState()8180 }8281 default:8382 m.cursor.Move(m.keys.CursorKeyMap, msg, m.size-1, m.size-1)@@ -91,10 +90,15 @@ func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {
9190 // otherwise clears it to unmarked.9291 func (m *Model) toggleMark(mark cellMark) {9392 if m.marks[m.cursor.Y][m.cursor.X] == mark {94- m.marks[m.cursor.Y][m.cursor.X] = unmarked93+ m.userMarks[m.cursor.Y][m.cursor.X] = unmarked9594 } else {96- m.marks[m.cursor.Y][m.cursor.X] = mark95+ m.userMarks[m.cursor.Y][m.cursor.X] = mark9796 }97+ m.recomputeState()98+}99+100+func (m *Model) recomputeState() {101+ m.marks = propagateRequiredMarks(m.numbers, m.userMarks, m.size)98102 m.solved = m.checkSolved()99103 m.conflicts = computeConflicts(m.numbers, m.marks, m.size)100104 }@@ -120,9 +124,8 @@ func (m Model) IsSolved() bool {
120124 }121125 122126 func (m Model) Reset() game.Gamer {123- m.marks = cloneMarks(m.initialMarks)124- m.solved = m.checkSolved()125- m.conflicts = computeConflicts(m.numbers, m.marks, m.size)127+ m.userMarks = cloneMarks(m.initialUserMarks)128+ m.recomputeState()126129 m.cursor = game.Cursor{}127130 return m128131 }@@ -3,8 +3,10 @@ package hitori
33 import (44 "encoding/json"55 "math/rand/v2"6+ "strings"67 "testing"78 9+ tea "charm.land/bubbletea/v2"810 "github.com/FelineStateMachine/puzzletea/game"911 )1012 @@ -96,6 +98,146 @@ func TestCloneMarks(t *testing.T) {
9698 }9799 }98100 101+// --- Rendering: cursor + mark visibility (P0) ---102+103+func TestCellView_CursorUnmarkedPreservesNumber(t *testing.T) {104+ got := cellView('5', unmarked, true, false, false, false, false)105+ if !strings.Contains(got, "5") {106+ t.Fatalf("cursor unmarked cell should show number, got %q", got)107+ }108+ if strings.Contains(got, game.CursorLeft) || strings.Contains(got, game.CursorRight) {109+ t.Fatalf("cursor unmarked cell should not use cursor brackets, got %q", got)110+ }111+}112+113+func TestCellView_CursorCircledPreservesNumberAndDiffersFromUnmarked(t *testing.T) {114+ unmarkedCell := cellView('5', unmarked, true, false, false, false, false)115+ circledCell := cellView('5', circled, true, false, false, false, false)116+117+ if !strings.Contains(circledCell, "5") {118+ t.Fatalf("cursor circled cell should show number, got %q", circledCell)119+ }120+ if circledCell == unmarkedCell {121+ t.Fatalf("cursor circled cell should differ from cursor unmarked cell, got %q", circledCell)122+ }123+}124+125+func TestCellView_CursorShadedShowsVisiblePayload(t *testing.T) {126+ got := cellView('5', shaded, true, false, false, false, false)127+ if !strings.Contains(got, "█") {128+ t.Fatalf("cursor shaded cell should show visible shaded payload, got %q", got)129+ }130+}131+132+func TestCellView_CursorConflictVariantsDifferFromPlainCursor(t *testing.T) {133+ plainCircled := cellView('5', circled, true, false, false, false, false)134+ conflictCircled := cellView('5', circled, true, false, false, false, true)135+ plainShaded := cellView('5', shaded, true, false, false, false, false)136+ conflictShaded := cellView('5', shaded, true, false, false, false, true)137+138+ if conflictCircled == plainCircled {139+ t.Fatalf("cursor circled conflict should differ from plain cursor circled, got %q", conflictCircled)140+ }141+ if conflictShaded == plainShaded {142+ t.Fatalf("cursor shaded conflict should differ from plain cursor shaded, got %q", conflictShaded)143+ }144+}145+146+func TestCellView_CursorSolvedShadedPreservesShadedPayload(t *testing.T) {147+ got := cellView('5', shaded, true, false, false, true, false)148+ if !strings.Contains(got, "█") {149+ t.Fatalf("cursor solved shaded cell should preserve shaded payload, got %q", got)150+ }151+}152+153+// --- Propagation: required moves (P0) ---154+155+func TestPropagateRequiredMarks_ShadedCellForcesOrthogonalWhite(t *testing.T) {156+ numbers := makeGrid("123", "456", "789")157+ userMarks := makeMarks("...", ".X.", "...")158+159+ got := propagateRequiredMarks(numbers, userMarks, 3)160+ want := makeMarks(".O.", "OXO", ".O.")161+162+ for y := range 3 {163+ for x := range 3 {164+ if got[y][x] != want[y][x] {165+ t.Fatalf("mark[%d][%d] = %v, want %v", y, x, got[y][x], want[y][x])166+ }167+ }168+ }169+}170+171+func TestPropagateRequiredMarks_CircledRowDuplicateForcesPeersBlack(t *testing.T) {172+ numbers := makeGrid("121", "345", "678")173+ userMarks := makeMarks("O..", "...", "...")174+175+ got := propagateRequiredMarks(numbers, userMarks, 3)176+177+ if got[0][0] != circled {178+ t.Fatalf("mark[0][0] = %v, want circled", got[0][0])179+ }180+ if got[0][2] != shaded {181+ t.Fatalf("mark[0][2] = %v, want shaded", got[0][2])182+ }183+}184+185+func TestPropagateRequiredMarks_CircledColumnDuplicateForcesPeersBlack(t *testing.T) {186+ numbers := makeGrid("123", "456", "173")187+ userMarks := makeMarks("O..", "...", "...")188+189+ got := propagateRequiredMarks(numbers, userMarks, 3)190+191+ if got[2][0] != shaded {192+ t.Fatalf("mark[2][0] = %v, want shaded", got[2][0])193+ }194+}195+196+func TestPropagateRequiredMarks_OnlyRemainingDuplicateSurvivorBecomesWhite(t *testing.T) {197+ numbers := makeGrid("111", "234", "567")198+ userMarks := makeMarks("X.X", "...", "...")199+200+ got := propagateRequiredMarks(numbers, userMarks, 3)201+202+ if got[0][1] != circled {203+ t.Fatalf("mark[0][1] = %v, want circled", got[0][1])204+ }205+}206+207+func TestPropagateRequiredMarks_DoesNotCascadeFromDerivedMarks(t *testing.T) {208+ numbers := makeGrid("1223", "3412", "4321", "2143")209+ userMarks := makeMarks("X...", "....", "....", "....")210+211+ got := propagateRequiredMarks(numbers, userMarks, 4)212+213+ if got[0][1] != circled {214+ t.Fatalf("mark[0][1] = %v, want circled", got[0][1])215+ }216+ if got[0][2] != unmarked {217+ t.Fatalf("mark[0][2] = %v, want unmarked", got[0][2])218+ }219+ if got[0][3] != unmarked {220+ t.Fatalf("mark[0][3] = %v, want unmarked", got[0][3])221+ }222+ if got[1][2] != unmarked {223+ t.Fatalf("mark[1][2] = %v, want unmarked", got[1][2])224+ }225+}226+227+func TestPropagateRequiredMarks_PreservesConflictingUserMarks(t *testing.T) {228+ numbers := makeGrid("1223", "3412", "4321", "2143")229+ userMarks := makeMarks("XX..", "....", "....", "....")230+231+ got := propagateRequiredMarks(numbers, userMarks, 4)232+233+ if got[0][0] != shaded {234+ t.Fatalf("mark[0][0] = %v, want shaded", got[0][0])235+ }236+ if got[0][1] != shaded {237+ t.Fatalf("mark[0][1] = %v, want shaded", got[0][1])238+ }239+}240+99241 // --- Validation: hasNoDuplicatesInRows (P0) ---100242 101243 func TestHasNoDuplicatesInRows(t *testing.T) {@@ -589,7 +731,7 @@ func TestSaveLoadRoundTrip(t *testing.T) {
589731 }590732 591733 func TestSaveLoadRoundTrip_WithMarks(t *testing.T) {592- numbers := makeGrid("1213", "2341", "3124", "1432")734+ numbers := makeGrid("1223", "3412", "4321", "2143")593735 mode := testMode(4)594736 gamer, err := New(mode, numbers)595737 if err != nil {@@ -598,8 +740,9 @@ func TestSaveLoadRoundTrip_WithMarks(t *testing.T) {
598740 599741 // Apply some marks via the model.600742 m := gamer.(Model)601- m.marks[0][0] = shaded602- m.marks[1][1] = circled743+ m.userMarks[0][0] = shaded744+ m.userMarks[1][1] = circled745+ m.recomputeState()603746 604747 data, err := m.GetSave()605748 if err != nil {@@ -611,11 +754,14 @@ func TestSaveLoadRoundTrip_WithMarks(t *testing.T) {
611754 t.Fatalf("ImportModel: %v", err)612755 }613756 614- if loaded.marks[0][0] != shaded {615- t.Error("shaded mark not preserved")757+ if loaded.userMarks[0][0] != shaded {758+ t.Error("shaded user mark not preserved")759+ }760+ if loaded.userMarks[1][1] != circled {761+ t.Error("circled user mark not preserved")616762 }617- if loaded.marks[1][1] != circled {618- t.Error("circled mark not preserved")763+ if loaded.marks[0][1] != circled {764+ t.Error("derived white mark not recomputed on load")619765 }620766 }621767 @@ -680,15 +826,91 @@ func TestModel_Reset(t *testing.T) {
680826 g, _ := New(mode, numbers)681827 682828 m := g.(Model)683- m.marks[0][0] = shaded684- m.marks[1][1] = circled829+ m.userMarks[0][0] = shaded830+ m.userMarks[1][1] = circled831+ m.recomputeState()685832 686833 reset := m.Reset().(Model)834+ if reset.userMarks[0][0] != unmarked || reset.userMarks[1][1] != unmarked {835+ t.Error("Reset did not clear user marks")836+ }687837 if reset.marks[0][0] != unmarked || reset.marks[1][1] != unmarked {688838 t.Error("Reset did not clear marks")689839 }690840 }691841 842+func TestModel_Update_ShadeRecomputesRequiredMoves(t *testing.T) {843+ mode := testMode(4)844+ numbers := makeGrid("1223", "3412", "4321", "2143")845+ g, err := New(mode, numbers)846+ if err != nil {847+ t.Fatalf("New: %v", err)848+ }849+850+ next, _ := g.Update(tea.KeyPressMsg{Code: 'x', Text: "x"})851+ m := next.(Model)852+853+ if m.userMarks[0][0] != shaded {854+ t.Fatalf("user mark at (0,0) = %v, want shaded", m.userMarks[0][0])855+ }856+ if m.marks[0][1] != circled {857+ t.Fatalf("effective mark at (1,0) = %v, want circled", m.marks[0][1])858+ }859+ if m.marks[0][2] != unmarked {860+ t.Fatalf("effective mark at (2,0) = %v, want unmarked", m.marks[0][2])861+ }862+ if m.solved {863+ t.Fatal("single user move should not auto-solve the puzzle")864+ }865+ if m.marks[0][3] != unmarked {866+ t.Fatalf("effective mark at (3,0) = %v, want unmarked", m.marks[0][3])867+ }868+}869+870+func TestModel_Update_ClearSourceRetractsDerivedMarks(t *testing.T) {871+ mode := testMode(4)872+ numbers := makeGrid("1223", "3412", "4321", "2143")873+ g, err := New(mode, numbers)874+ if err != nil {875+ t.Fatalf("New: %v", err)876+ }877+878+ next, _ := g.Update(tea.KeyPressMsg{Code: 'x', Text: "x"})879+ m := next.(Model)880+ m.userMarks[0][0] = unmarked881+ m.recomputeState()882+883+ if m.userMarks[0][0] != unmarked {884+ t.Fatalf("user mark at (0,0) = %v, want unmarked", m.userMarks[0][0])885+ }886+ if m.marks[0][1] != unmarked {887+ t.Fatalf("effective mark at (1,0) = %v, want unmarked", m.marks[0][1])888+ }889+}890+891+func TestModel_ContradictingUserMoveIsPreservedAndConflicts(t *testing.T) {892+ mode := testMode(4)893+ numbers := makeGrid("1223", "3412", "4321", "2143")894+ g, err := New(mode, numbers)895+ if err != nil {896+ t.Fatalf("New: %v", err)897+ }898+899+ m := g.(Model)900+ m.userMarks = makeMarks("XX..", "....", "....", "....")901+ m.recomputeState()902+903+ if m.marks[0][1] != shaded {904+ t.Fatalf("effective mark at (1,0) = %v, want shaded", m.marks[0][1])905+ }906+ if !m.conflicts[0][0] || !m.conflicts[0][1] {907+ t.Fatal("expected adjacent shaded conflict for contradicting user move")908+ }909+ if m.solved {910+ t.Fatal("contradicting user move should not solve the puzzle")911+ }912+}913+692914 func TestModel_SetTitle(t *testing.T) {693915 mode := testMode(3)694916 numbers := makeGrid("123", "231", "312")@@ -1,5 +1,97 @@
11 package hitori22 3+func propagateRequiredMarks(numbers grid, userMarks [][]cellMark, size int) [][]cellMark {4+ marks := cloneMarks(userMarks)5+ forceWhiteNeighborsFromDrivers(marks, userMarks, size)6+ forceDuplicateGroupsInRows(numbers, marks, userMarks, size)7+ forceDuplicateGroupsInCols(numbers, marks, userMarks, size)8+ return marks9+}10+11+func forceWhiteNeighborsFromDrivers(marks, drivers [][]cellMark, size int) {12+ dirs := [4]cellPos{{0, -1}, {0, 1}, {-1, 0}, {1, 0}}13+14+ for y := range size {15+ for x := range size {16+ if drivers[y][x] != shaded {17+ continue18+ }19+20+ for _, d := range dirs {21+ nx, ny := x+d.x, y+d.y22+ if nx < 0 || nx >= size || ny < 0 || ny >= size {23+ continue24+ }25+ if marks[ny][nx] != unmarked {26+ continue27+ }28+ marks[ny][nx] = circled29+ }30+ }31+ }32+}33+34+func forceDuplicateGroupsInRows(numbers grid, marks, drivers [][]cellMark, size int) {35+ for y := range size {36+ seen := map[rune][]cellPos{}37+ for x := range size {38+ num := numbers[y][x]39+ seen[num] = append(seen[num], cellPos{x, y})40+ }41+ for _, positions := range seen {42+ forceDuplicateGroup(marks, drivers, positions)43+ }44+ }45+}46+47+func forceDuplicateGroupsInCols(numbers grid, marks, drivers [][]cellMark, size int) {48+ for x := range size {49+ seen := map[rune][]cellPos{}50+ for y := range size {51+ num := numbers[y][x]52+ seen[num] = append(seen[num], cellPos{x, y})53+ }54+ for _, positions := range seen {55+ forceDuplicateGroup(marks, drivers, positions)56+ }57+ }58+}59+60+func forceDuplicateGroup(marks, drivers [][]cellMark, positions []cellPos) {61+ if len(positions) <= 1 {62+ return63+ }64+65+ anyCircled := false66+ nonShadedCount := 067+ survivor := cellPos{-1, -1}68+69+ for _, p := range positions {70+ switch drivers[p.y][p.x] {71+ case circled:72+ anyCircled = true73+ nonShadedCount++74+ survivor = p75+ case unmarked:76+ nonShadedCount++77+ survivor = p78+ }79+ }80+81+ if anyCircled {82+ for _, p := range positions {83+ if marks[p.y][p.x] != unmarked {84+ continue85+ }86+ marks[p.y][p.x] = shaded87+ }88+ }89+90+ if nonShadedCount == 1 && survivor.x >= 0 && marks[survivor.y][survivor.x] == unmarked {91+ marks[survivor.y][survivor.x] = circled92+ }93+}94+395 // hasNoDuplicatesInRows returns true if no row contains a duplicate number496 // among the non-shaded cells.597 func hasNoDuplicatesInRows(numbers grid, marks [][]cellMark, size int) bool {@@ -9,37 +9,15 @@ import (
99 )1010 1111 const cellWidth = 312+const shadedCellDisplay = " █ "1213 1314 func cellView(num rune, mark cellMark, isCursor, inCursorRow, inCursorCol, solved, conflict bool) string {1415 p := theme.Current()15- var s lipgloss.Style16- var display string16+ s, display := hitoriCellBase(mark, num)1717 18- switch mark {19- case shaded:20- s = lipgloss.NewStyle().21- Foreground(p.Surface).22- Background(p.Surface)23- display = " "24- case circled:25- s = lipgloss.NewStyle().26- Foreground(p.Info).27- Background(p.BG)28- display = fmt.Sprintf(" %c ", num)29- default:30- s = lipgloss.NewStyle().31- Foreground(p.FG).32- Background(p.BG)33- display = fmt.Sprintf(" %c ", num)34- }35-36- // Priority: cursor+solved > cursor > solved > conflict > crosshair > base.37- if isCursor && solved {38- s = game.CursorSolvedStyle()39- display = game.CursorLeft + fmt.Sprintf("%c", num) + game.CursorRight40- } else if isCursor {41- s = game.CursorStyle()42- display = game.CursorLeft + fmt.Sprintf("%c", num) + game.CursorRight18+ // Priority: cursor+solved > cursor+conflict > cursor > solved > conflict > crosshair > base.19+ if isCursor {20+ s = hitoriCursorStyle(mark, solved, conflict)4321 } else if solved {4422 s = s.Foreground(p.SolvedFG).Background(p.SuccessBG)4523 } else if conflict {@@ -51,6 +29,68 @@ func cellView(num rune, mark cellMark, isCursor, inCursorRow, inCursorCol, solve
5129 return s.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(display)5230 }5331 32+func hitoriCellBase(mark cellMark, num rune) (lipgloss.Style, string) {33+ p := theme.Current()34+35+ switch mark {36+ case shaded:37+ return lipgloss.NewStyle().38+ Foreground(p.Surface).39+ Background(p.Surface),40+ shadedCellDisplay41+ case circled:42+ return lipgloss.NewStyle().43+ Foreground(p.Info).44+ Background(p.BG),45+ fmt.Sprintf(" %c ", num)46+ default:47+ return lipgloss.NewStyle().48+ Foreground(p.FG).49+ Background(p.BG),50+ fmt.Sprintf(" %c ", num)51+ }52+}53+54+func hitoriCursorStyle(mark cellMark, solved, conflict bool) lipgloss.Style {55+ p := theme.Current()56+ style := lipgloss.NewStyle().Bold(true)57+58+ switch {59+ case solved:60+ style = style.Background(p.SuccessBG)61+ switch mark {62+ case circled:63+ style = style.Foreground(p.Info)64+ case shaded:65+ style = style.Foreground(p.SolvedFG)66+ default:67+ style = style.Foreground(game.CursorFG())68+ }69+ case conflict:70+ style = style.Background(game.ConflictBG()).Underline(true)71+ switch mark {72+ case circled:73+ style = style.Foreground(p.Info)74+ case shaded:75+ style = style.Foreground(game.CursorFG())76+ default:77+ style = style.Foreground(game.CursorFG())78+ }79+ default:80+ style = style.Background(game.CursorBG())81+ switch mark {82+ case circled:83+ style = style.Foreground(p.Info)84+ case shaded:85+ style = style.Foreground(game.CursorFG())86+ default:87+ style = style.Foreground(game.CursorFG())88+ }89+ }90+91+ return style92+}93+5494 func gridView(numbers grid, marks [][]cellMark, c game.Cursor, solved bool, conflicts [][]bool) string {5595 colors := game.DefaultBorderColors()5696 @@ -461,6 +461,12 @@ func ensureJSONLTestAdapters() {
461461 register("Sudoku", buildPayloadAdapter("Sudoku", func(save []byte) (any, error) {462462 return ParseSudokuPrintData(save)463463 }), "sudoku")464+ register("Fillomino", buildPayloadAdapter("Fillomino", func(save []byte) (any, error) {465+ return ParseFillominoPrintData(save)466+ }), "fillomino")467+ register("Ripple Effect", buildPayloadAdapter("Ripple Effect", func(save []byte) (any, error) {468+ return ParseRippleEffectPrintData(save)469+ }), "rippleeffect", "ripple")464470 register("Word Search", buildPayloadAdapter("Word Search", func(save []byte) (any, error) {465471 return ParseWordSearchPrintData(save)466472 }), "wordsearch")@@ -62,6 +62,20 @@ type sudokuSave struct {
6262 Provided []sudokuCell `json:"provided"`6363 }6464 65+type fillominoSave struct {66+ Width int `json:"width"`67+ Height int `json:"height"`68+ State string `json:"state"`69+ Provided string `json:"provided"`70+}71+72+type rippleEffectSave struct {73+ Width int `json:"width"`74+ Height int `json:"height"`75+ Givens string `json:"givens"`76+ Cages []RippleEffectCage `json:"cages"`77+}78+6579 type sudokuCell struct {6680 X int `json:"x"`6781 Y int `json:"y"`@@ -332,6 +346,68 @@ func ParseSudokuPrintData(saveData []byte) (*SudokuData, error) {
332346 return &SudokuData{Givens: givens}, nil333347 }334348 349+func ParseFillominoPrintData(saveData []byte) (*FillominoData, error) {350+ if len(strings.TrimSpace(string(saveData))) == 0 {351+ return nil, nil352+ }353+354+ var save fillominoSave355+ if err := json.Unmarshal(saveData, &save); err != nil {356+ return nil, fmt.Errorf("decode fillomino save: %w", err)357+ }358+ if save.Width <= 0 || save.Height <= 0 {359+ return nil, nil360+ }361+362+ state, err := parseNumberGrid(save.State, save.Width, save.Height)363+ if err != nil {364+ return nil, err365+ }366+ provided := parseProvidedMask(save.Provided, save.Width, save.Height)367+368+ givens := make([][]int, save.Height)369+ for y := 0; y < save.Height; y++ {370+ givens[y] = make([]int, save.Width)371+ for x := 0; x < save.Width; x++ {372+ if provided[y][x] {373+ givens[y][x] = state[y][x]374+ }375+ }376+ }377+378+ return &FillominoData{379+ Width: save.Width,380+ Height: save.Height,381+ Givens: givens,382+ }, nil383+}384+385+func ParseRippleEffectPrintData(saveData []byte) (*RippleEffectData, error) {386+ if len(strings.TrimSpace(string(saveData))) == 0 {387+ return nil, nil388+ }389+390+ var save rippleEffectSave391+ if err := json.Unmarshal(saveData, &save); err != nil {392+ return nil, fmt.Errorf("decode ripple effect save: %w", err)393+ }394+ if save.Width <= 0 || save.Height <= 0 {395+ return nil, nil396+ }397+398+ givens, err := parseNumberGrid(save.Givens, save.Width, save.Height)399+ if err != nil {400+ return nil, err401+ }402+403+ return &RippleEffectData{404+ Width: save.Width,405+ Height: save.Height,406+ Givens: givens,407+ Cages: append([]RippleEffectCage(nil), save.Cages...),408+ }, nil409+}410+335411 func ParseWordSearchPrintData(saveData []byte) (*WordSearchData, error) {336412 if len(strings.TrimSpace(string(saveData))) == 0 {337413 return nil, nil@@ -399,6 +475,59 @@ func ParseWordSearchPrintData(saveData []byte) (*WordSearchData, error) {
399475 }, nil400476 }401477 478+func parseNumberGrid(encoded string, width, height int) ([][]int, error) {479+ rows := strings.Split(strings.TrimSpace(encoded), "\n")480+ grid := make([][]int, height)481+ for y := 0; y < height; y++ {482+ grid[y] = make([]int, width)483+ if y >= len(rows) {484+ continue485+ }486+487+ fields := strings.Fields(rows[y])488+ if len(fields) == 0 {489+ fields = splitCompactFields(rows[y])490+ }491+ if len(fields) != width {492+ return nil, fmt.Errorf("decode number grid: invalid row width")493+ }494+ for x := 0; x < width; x++ {495+ if fields[x] == "." {496+ continue497+ }498+ value, err := strconv.Atoi(fields[x])499+ if err != nil {500+ return nil, fmt.Errorf("decode number grid: %w", err)501+ }502+ grid[y][x] = value503+ }504+ }505+ return grid, nil506+}507+508+func parseProvidedMask(encoded string, width, height int) [][]bool {509+ rows := strings.Split(encoded, "\n")510+ mask := make([][]bool, height)511+ for y := 0; y < height; y++ {512+ mask[y] = make([]bool, width)513+ if y >= len(rows) {514+ continue515+ }516+ for x := 0; x < width && x < len(rows[y]); x++ {517+ mask[y][x] = rows[y][x] == '#'518+ }519+ }520+ return mask521+}522+523+func splitCompactFields(row string) []string {524+ fields := make([]string, 0, len(row))525+ for _, r := range row {526+ fields = append(fields, string(r))527+ }528+ return fields529+}530+402531 func isSudokuCellInBounds(x, y int) bool {403532 return x >= 0 && x < 9 && y >= 0 && y < 9404533 }@@ -181,3 +181,54 @@ func TestParseTakuzuPrintData(t *testing.T) {
181181 t.Fatalf("row 3 col 1 = %q, want empty", got)182182 }183183 }184+185+func TestParseFillominoPrintData(t *testing.T) {186+ save := []byte(`{"width":3,"height":2,"state":"1 . 2\n. 3 .","provided":"#.#\n.#."}`)187+188+ data, err := ParseFillominoPrintData(save)189+ if err != nil {190+ t.Fatal(err)191+ }192+ if data == nil {193+ t.Fatal("expected fillomino print data")194+ }195+ if got, want := data.Width, 3; got != want {196+ t.Fatalf("width = %d, want %d", got, want)197+ }198+ if got, want := data.Height, 2; got != want {199+ t.Fatalf("height = %d, want %d", got, want)200+ }201+ if got, want := data.Givens[0][0], 1; got != want {202+ t.Fatalf("givens[0][0] = %d, want %d", got, want)203+ }204+ if got := data.Givens[0][1]; got != 0 {205+ t.Fatalf("givens[0][1] = %d, want 0", got)206+ }207+ if got, want := data.Givens[1][1], 3; got != want {208+ t.Fatalf("givens[1][1] = %d, want %d", got, want)209+ }210+}211+212+func TestParseRippleEffectPrintData(t *testing.T) {213+ save := []byte(`{"width":3,"height":3,"givens":"1 . 1\n. 1 3\n3 . .","cages":[{"size":2,"cells":[{"x":0,"y":0},{"x":1,"y":0}]},{"size":1,"cells":[{"x":2,"y":0}]},{"size":3,"cells":[{"x":0,"y":1},{"x":0,"y":2},{"x":1,"y":2}]},{"size":3,"cells":[{"x":1,"y":1},{"x":2,"y":1},{"x":2,"y":2}]}]}`)214+215+ data, err := ParseRippleEffectPrintData(save)216+ if err != nil {217+ t.Fatal(err)218+ }219+ if data == nil {220+ t.Fatal("expected ripple effect print data")221+ }222+ if got, want := data.Width, 3; got != want {223+ t.Fatalf("width = %d, want %d", got, want)224+ }225+ if got, want := len(data.Cages), 4; got != want {226+ t.Fatalf("cage count = %d, want %d", got, want)227+ }228+ if got, want := data.Givens[0][0], 1; got != want {229+ t.Fatalf("givens[0][0] = %d, want %d", got, want)230+ }231+ if got := data.Givens[2][1]; got != 0 {232+ t.Fatalf("givens[2][1] = %d, want 0", got)233+ }234+}@@ -89,6 +89,29 @@ type SudokuData struct {
8989 Givens [9][9]int `json:"givens"`9090 }9191 92+type FillominoData struct {93+ Width int94+ Height int95+ Givens [][]int96+}97+98+type RippleEffectCell struct {99+ X int `json:"x"`100+ Y int `json:"y"`101+}102+103+type RippleEffectCage struct {104+ Size int `json:"size"`105+ Cells []RippleEffectCell `json:"cells"`106+}107+108+type RippleEffectData struct {109+ Width int110+ Height int111+ Givens [][]int112+ Cages []RippleEffectCage113+}114+92115 type WordSearchData struct {93116 Width int `json:"width"`94117 Height int `json:"height"`@@ -16,26 +16,6 @@ func Normalize(s string) string {
1616 return game.NormalizeName(s)1717 }1818 19-// Category finds a game category by name (case-insensitive,20-// hyphen/underscore-tolerant, with alias support).21-func Category(name string, definitions []game.Definition) (game.Category, error) {22- norm := game.NormalizeName(name)23-24- for _, def := range definitions {25- if game.NormalizeName(def.Name) == norm {26- return def.Category(), nil27- }28- for _, alias := range def.Aliases {29- if game.NormalizeName(alias) == norm {30- return def.Category(), nil31- }32- }33- }34-35- return game.Category{}, fmt.Errorf("unknown game %q\n\nAvailable games:\n %s",36- name, joinLines(CategoryNames(definitions)))37-}38-3919 // Mode finds a mode within a category by name. If name is empty,4020 // returns the first (default) mode.4121 func Mode(cat game.Category, name string) (game.Spawner, string, error) {@@ -77,15 +57,6 @@ func Mode(cat game.Category, name string) (game.Spawner, string, error) {
7757 name, cat.Name, strings.Join(ModeNames(cat), "\n "))7858 }7959 80-// CategoryNames returns the display names of all game categories.81-func CategoryNames(definitions []game.Definition) []string {82- names := make([]string, 0, len(definitions))83- for _, def := range definitions {84- names = append(names, def.Name)85- }86- return names87-}88-8960 // ModeNames returns the display names of all modes in a category.9061 func ModeNames(cat game.Category) []string {9162 names := make([]string, 0, len(cat.Modes))@@ -160,14 +131,3 @@ func SeededMode(seed string, definitions []game.Definition) (game.SeededSpawner,
160131 }161132 return best.spawner, best.gameType, best.mode, nil162133 }163-164-func joinLines(values []string) string {165- if len(values) == 0 {166- return ""167- }168- result := values[0]169- for i := 1; i < len(values); i++ {170- result += "\n " + values[i]171- }172- return result173-}@@ -5,29 +5,12 @@ import (
55 "testing"66 77 "charm.land/bubbles/v2/list"8+ "github.com/FelineStateMachine/puzzletea/catalog"89 "github.com/FelineStateMachine/puzzletea/game"9- "github.com/FelineStateMachine/puzzletea/hashiwokakero"10- "github.com/FelineStateMachine/puzzletea/hitori"1110 "github.com/FelineStateMachine/puzzletea/lightsout"12- "github.com/FelineStateMachine/puzzletea/nonogram"13- "github.com/FelineStateMachine/puzzletea/nurikabe"14- "github.com/FelineStateMachine/puzzletea/shikaku"15- "github.com/FelineStateMachine/puzzletea/sudoku"16- "github.com/FelineStateMachine/puzzletea/takuzu"17- "github.com/FelineStateMachine/puzzletea/wordsearch"1811 )1912 20-var testDefinitions = []game.Definition{21- hashiwokakero.Definition,22- hitori.Definition,23- lightsout.Definition,24- nonogram.Definition,25- nurikabe.Definition,26- shikaku.Definition,27- sudoku.Definition,28- takuzu.Definition,29- wordsearch.Definition,30-}13+var testDefinitions = catalog.All3114 3215 func TestNormalize(t *testing.T) {3316 tests := []struct {@@ -58,94 +41,9 @@ func TestNormalize(t *testing.T) {
5841 }5942 }6043 61-func TestCategory(t *testing.T) {62- // Exact and case-insensitive matches.63- exactTests := []struct {64- name string65- input string66- wantName string67- }{68- {"exact Nonogram", "Nonogram", "Nonogram"},69- {"exact Sudoku", "Sudoku", "Sudoku"},70- {"exact Lights Out", "Lights Out", "Lights Out"},71- {"exact Word Search", "Word Search", "Word Search"},72- {"exact Hashiwokakero", "Hashiwokakero", "Hashiwokakero"},73- {"exact Takuzu", "Takuzu", "Takuzu"},74- {"lowercase", "nonogram", "Nonogram"},75- {"all caps", "SUDOKU", "Sudoku"},76- {"hyphenated", "lights-out", "Lights Out"},77- {"underscored", "lights_out", "Lights Out"},78- {"mixed case hyphen", "Word-Search", "Word Search"},79- }80-81- for _, tt := range exactTests {82- t.Run(tt.name, func(t *testing.T) {83- cat, err := Category(tt.input, testDefinitions)84- if err != nil {85- t.Fatalf("Category(%q) error: %v", tt.input, err)86- }87- if cat.Name != tt.wantName {88- t.Errorf("Category(%q).Name = %q, want %q", tt.input, cat.Name, tt.wantName)89- }90- })91- }92-93- // All aliases.94- aliasTests := []struct {95- alias string96- wantName string97- }{98- {"hashi", "Hashiwokakero"},99- {"bridges", "Hashiwokakero"},100- {"hitori", "Hitori"},101- {"lights", "Lights Out"},102- {"ws", "Word Search"},103- {"words", "Word Search"},104- {"wordsearch", "Word Search"},105- {"binairo", "Takuzu"},106- {"binary", "Takuzu"},107- {"nonogram", "Nonogram"},108- {"nurikabe", "Nurikabe"},109- {"islands", "Nurikabe"},110- {"sea", "Nurikabe"},111- {"sudoku", "Sudoku"},112- {"takuzu", "Takuzu"},113- }114-115- for _, tt := range aliasTests {116- t.Run("alias_"+tt.alias, func(t *testing.T) {117- cat, err := Category(tt.alias, testDefinitions)118- if err != nil {119- t.Fatalf("Category(%q) error: %v", tt.alias, err)120- }121- if cat.Name != tt.wantName {122- t.Errorf("Category(%q).Name = %q, want %q", tt.alias, cat.Name, tt.wantName)123- }124- })125- }126-127- // Error cases.128- t.Run("unknown game", func(t *testing.T) {129- _, err := Category("chess", testDefinitions)130- if err == nil {131- t.Fatal("expected error for unknown game")132- }133- if !strings.Contains(err.Error(), "unknown game") {134- t.Errorf("error = %q, want it to contain %q", err.Error(), "unknown game")135- }136- })137-138- t.Run("empty string", func(t *testing.T) {139- _, err := Category("", testDefinitions)140- if err == nil {141- t.Fatal("expected error for empty string")142- }143- })144-}145-14644 func TestMode(t *testing.T) {14745 // Find a known category with modes for testing.148- sudokuCat, err := Category("Sudoku", testDefinitions)46+ sudokuCat, err := catalog.Category("Sudoku")14947 if err != nil {15048 t.Fatal(err)15149 }@@ -206,37 +104,8 @@ func TestMode(t *testing.T) {
206104 })207105 }208106 209-func TestCategoryNames(t *testing.T) {210- names := CategoryNames(testDefinitions)211- if len(names) != len(testDefinitions) {212- t.Fatalf("len(CategoryNames()) = %d, want %d", len(names), len(testDefinitions))213- }214-215- // Verify all expected games are present.216- expected := map[string]bool{217- "Hashiwokakero": true,218- "Hitori": true,219- "Lights Out": true,220- "Nonogram": true,221- "Nurikabe": true,222- "Shikaku": true,223- "Sudoku": true,224- "Takuzu": true,225- "Word Search": true,226- }227- for _, n := range names {228- if !expected[n] {229- t.Errorf("unexpected category name: %q", n)230- }231- delete(expected, n)232- }233- for n := range expected {234- t.Errorf("missing category name: %q", n)235- }236-}237-238107 func TestModeNames(t *testing.T) {239- cat, err := Category("Lights Out", testDefinitions)108+ cat, err := catalog.Category("Lights Out")240109 if err != nil {241110 t.Fatal(err)242111 }@@ -0,0 +1,79 @@
1+package rippleeffect2+3+import (4+ "encoding/json"5+ "fmt"6+7+ "github.com/FelineStateMachine/puzzletea/game"8+)9+10+type Save struct {11+ Width int `json:"width"`12+ Height int `json:"height"`13+ State string `json:"state"`14+ Givens string `json:"givens"`15+ Cages []Cage `json:"cages"`16+ ModeTitle string `json:"mode_title"`17+}18+19+func (m Model) GetSave() ([]byte, error) {20+ save := Save{21+ Width: m.width,22+ Height: m.height,23+ State: encodeGrid(m.grid),24+ Givens: encodeGrid(m.givens),25+ Cages: m.geo.cages,26+ ModeTitle: m.modeTitle,27+ }28+ data, err := json.Marshal(save)29+ if err != nil {30+ return nil, fmt.Errorf("marshal ripple effect save: %w", err)31+ }32+ return data, nil33+}34+35+func ImportModel(data []byte) (*Model, error) {36+ var save Save37+ if err := json.Unmarshal(data, &save); err != nil {38+ return nil, fmt.Errorf("unmarshal ripple effect save: %w", err)39+ }40+41+ geo, err := buildGeometry(save.Width, save.Height, save.Cages)42+ if err != nil {43+ return nil, fmt.Errorf("build ripple effect geometry: %w", err)44+ }45+46+ state, err := parseGrid(save.State, save.Width, save.Height)47+ if err != nil {48+ return nil, fmt.Errorf("decode ripple effect state: %w", err)49+ }50+ givens, err := parseGrid(save.Givens, save.Width, save.Height)51+ if err != nil {52+ return nil, fmt.Errorf("decode ripple effect givens: %w", err)53+ }54+55+ for y := range save.Height {56+ for x := range save.Width {57+ if givens[y][x] != 0 && state[y][x] != givens[y][x] {58+ return nil, fmt.Errorf("given at (%d,%d) does not match current state", x, y)59+ }60+ if givens[y][x] != 0 && !valueAllowed(geo, givens, point{x: x, y: y}, givens[y][x]) {61+ return nil, fmt.Errorf("invalid given at (%d,%d)", x, y)62+ }63+ }64+ }65+66+ m := &Model{67+ width: save.Width,68+ height: save.Height,69+ geo: geo,70+ grid: state,71+ initialGrid: cloneGrid(givens),72+ givens: givens,73+ cursor: game.Cursor{},74+ keys: DefaultKeyMap,75+ modeTitle: save.ModeTitle,76+ }77+ m.recompute()78+ return m, nil79+}@@ -0,0 +1,74 @@
1+package rippleeffect2+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 Mode struct {15+ game.BaseMode16+ Size int17+ MaxCage int18+ GivenRatio float6419+}20+21+var (22+ _ game.Mode = Mode{}23+ _ game.Spawner = Mode{}24+ _ game.SeededSpawner = Mode{}25+)26+27+func NewMode(title, description string, size, maxCage int, givenRatio float64) Mode {28+ return Mode{29+ BaseMode: game.NewBaseMode(title, description),30+ Size: size,31+ MaxCage: maxCage,32+ GivenRatio: givenRatio,33+ }34+}35+36+func (m Mode) Spawn() (game.Gamer, error) {37+ puzzle, err := GeneratePuzzle(m.Size, m.Size, m.MaxCage, m.GivenRatio)38+ if err != nil {39+ return nil, err40+ }41+ return New(m, puzzle)42+}43+44+func (m Mode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) {45+ puzzle, err := GeneratePuzzleSeeded(m.Size, m.Size, m.MaxCage, m.GivenRatio, rng)46+ if err != nil {47+ return nil, err48+ }49+ return New(m, puzzle)50+}51+52+var Modes = []list.Item{53+ NewMode("Mini 5x5", "Small board with compact cages.", 5, 3, 0.76),54+ NewMode("Easy 6x6", "Gentle introduction to ripple spacing.", 6, 3, 0.72),55+ NewMode("Medium 7x7", "Balanced cages and row pressure.", 7, 4, 0.68),56+ NewMode("Hard 8x8", "Denser ripple interactions.", 8, 4, 0.64),57+ NewMode("Expert 9x9", "Wide board with longer deduction chains.", 9, 5, 0.60),58+}59+60+var DailyModes = []list.Item{61+ Modes[1],62+ Modes[2],63+ Modes[3],64+}65+66+var Definition = game.Definition{67+ Name: "Ripple Effect",68+ Description: "Place digits in cages without violating ripple distance.",69+ Aliases: []string{"ripple"},70+ Modes: Modes,71+ DailyModes: DailyModes,72+ Help: HelpContent,73+ Import: func(data []byte) (game.Gamer, error) { return ImportModel(data) },74+}@@ -0,0 +1,163 @@
1+package rippleeffect2+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+var _ game.Gamer = Model{}13+14+type Model struct {15+ width int16+ height int17+ geo *geometry18+ grid grid19+ initialGrid grid20+ givens grid21+ cursor game.Cursor22+ conflicts [][]bool23+ solved bool24+ keys KeyMap25+ modeTitle string26+ showFullHelp bool27+}28+29+func New(mode Mode, puzzle Puzzle) (game.Gamer, error) {30+ if puzzle.Width != mode.Size || puzzle.Height != mode.Size {31+ return nil, fmt.Errorf("ripple effect puzzle size %dx%d does not match mode %dx%d", puzzle.Width, puzzle.Height, mode.Size, mode.Size)32+ }33+34+ geo, err := buildGeometry(puzzle.Width, puzzle.Height, puzzle.Cages)35+ if err != nil {36+ return nil, err37+ }38+ if !gridsMatchPuzzle(puzzle.Givens, puzzle.Width, puzzle.Height) {39+ return nil, fmt.Errorf("ripple effect givens grid does not match puzzle size")40+ }41+42+ m := Model{43+ width: puzzle.Width,44+ height: puzzle.Height,45+ geo: geo,46+ grid: cloneGrid(puzzle.Givens),47+ initialGrid: cloneGrid(puzzle.Givens),48+ givens: cloneGrid(puzzle.Givens),49+ cursor: game.Cursor{},50+ keys: DefaultKeyMap,51+ modeTitle: mode.Title(),52+ }53+ m.recompute()54+ return m, nil55+}56+57+func gridsMatchPuzzle(g grid, width, height int) bool {58+ if len(g) != height {59+ return false60+ }61+ for y := range height {62+ if len(g[y]) != width {63+ return false64+ }65+ }66+ return true67+}68+69+func (m Model) Init() tea.Cmd {70+ return nil71+}72+73+func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {74+ switch msg := msg.(type) {75+ case game.HelpToggleMsg:76+ m.showFullHelp = msg.Show77+ case tea.KeyPressMsg:78+ switch {79+ case key.Matches(msg, m.keys.Clear):80+ m.setCell(0)81+ case key.Matches(msg, m.keys.FillValue):82+ value, _ := strconv.Atoi(msg.String())83+ m.setCell(value)84+ default:85+ m.cursor.Move(m.keys.CursorKeyMap, msg, m.width-1, m.height-1)86+ }87+ }88+89+ m.updateKeyBindings()90+ return m, nil91+}92+93+func (m *Model) setCell(value int) {94+ if m.solved || m.givens[m.cursor.Y][m.cursor.X] != 0 {95+ return96+ }97+ if value != 0 && value > m.geo.cageSizes[m.geo.cageGrid[m.cursor.Y][m.cursor.X]] {98+ return99+ }100+ m.grid[m.cursor.Y][m.cursor.X] = value101+ m.recompute()102+}103+104+func (m *Model) recompute() {105+ result := validateGridState(m.grid, m.geo)106+ m.conflicts = result.conflicts107+ m.solved = result.solved108+}109+110+func (m Model) View() string {111+ title := game.TitleBarView("Ripple Effect", m.modeTitle, m.solved)112+ body := gridView(m)113+ if m.solved {114+ return game.ComposeGameView(title, body)115+ }116+ status := statusBarView(m.showFullHelp)117+ return game.ComposeGameViewRows(title, body, game.StableRow(status, statusBarView(false), statusBarView(true)))118+}119+120+func (m Model) SetTitle(t string) game.Gamer {121+ m.modeTitle = t122+ return m123+}124+125+func (m Model) IsSolved() bool {126+ return m.solved127+}128+129+func (m Model) Reset() game.Gamer {130+ m.grid = cloneGrid(m.initialGrid)131+ m.cursor = game.Cursor{}132+ m.recompute()133+ return m134+}135+136+func (m Model) GetDebugInfo() string {137+ status := "In Progress"138+ if m.solved {139+ status = "Solved"140+ }141+142+ filled := 0143+ conflictCount := 0144+ for y := range m.height {145+ for x := range m.width {146+ if m.grid[y][x] != 0 {147+ filled++148+ }149+ if m.conflicts[y][x] {150+ conflictCount++151+ }152+ }153+ }154+155+ cageIdx := m.geo.cageGrid[m.cursor.Y][m.cursor.X]156+ return game.DebugHeader("Ripple Effect", [][2]string{157+ {"Status", status},158+ {"Cursor", fmt.Sprintf("(%d, %d)", m.cursor.X, m.cursor.Y)},159+ {"Filled Cells", fmt.Sprintf("%d / %d", filled, m.width*m.height)},160+ {"Conflict Count", fmt.Sprintf("%d", conflictCount)},161+ {"Current Cage", fmt.Sprintf("%d (size %d)", m.geo.cages[cageIdx].ID, m.geo.cageSizes[cageIdx])},162+ })163+}@@ -0,0 +1,126 @@
1+package rippleeffect2+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 "Ripple Effect" }14+func (printAdapter) Aliases() []string { return []string{"ripple effect", "rippleeffect", "ripple"} }15+16+func (printAdapter) BuildPDFPayload(save []byte) (any, error) {17+ return pdfexport.ParseRippleEffectPrintData(save)18+}19+20+func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error {21+ switch data := payload.(type) {22+ case *pdfexport.RippleEffectData:23+ renderRippleEffectPage(pdf, data)24+ }25+ return nil26+}27+28+func renderRippleEffectPage(pdf *fpdf.Fpdf, data *pdfexport.RippleEffectData) {29+ if data == nil || data.Width <= 0 || data.Height <= 0 {30+ return31+ }32+33+ pageW, pageH := pdf.GetPageSize()34+ pageNo := pdf.PageNo()35+ area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, 2)36+ cellSize := pdfexport.FitCompactCellSize(data.Width, data.Height, area)37+ if cellSize <= 0 {38+ return39+ }40+41+ blockW := float64(data.Width) * cellSize42+ blockH := float64(data.Height) * cellSize43+ startX, startY := pdfexport.CenteredOrigin(area, data.Width, data.Height, cellSize)44+45+ cageGrid := make([][]int, data.Height)46+ for y := 0; y < data.Height; y++ {47+ cageGrid[y] = make([]int, data.Width)48+ for x := 0; x < data.Width; x++ {49+ cageGrid[y][x] = -150+ }51+ }52+ for cageIdx, cage := range data.Cages {53+ for _, cell := range cage.Cells {54+ if cell.X >= 0 && cell.X < data.Width && cell.Y >= 0 && cell.Y < data.Height {55+ cageGrid[cell.Y][cell.X] = cageIdx56+ }57+ }58+ }59+ masks := buildBoundaryMasks(cageGrid)60+61+ pdf.SetDrawColor(150, 150, 150)62+ pdf.SetLineWidth(pdfexport.ThinGridLineMM)63+ for y := 0; y <= data.Height; y++ {64+ lineY := startY + float64(y)*cellSize65+ pdf.Line(startX, lineY, startX+blockW, lineY)66+ }67+ for x := 0; x <= data.Width; x++ {68+ lineX := startX + float64(x)*cellSize69+ pdf.Line(lineX, startY, lineX, startY+blockH)70+ }71+72+ pdf.SetDrawColor(35, 35, 35)73+ pdf.SetLineWidth(pdfexport.MajorGridLineMM)74+ for y := 0; y < data.Height; y++ {75+ for x := 0; x < data.Width; x++ {76+ cellX := startX + float64(x)*cellSize77+ cellY := startY + float64(y)*cellSize78+ mask := masks[y][x]79+ if mask.has(boundaryTop) {80+ pdf.Line(cellX, cellY, cellX+cellSize, cellY)81+ }82+ if mask.has(boundaryLeft) {83+ pdf.Line(cellX, cellY, cellX, cellY+cellSize)84+ }85+ if mask.has(boundaryRight) {86+ pdf.Line(cellX+cellSize, cellY, cellX+cellSize, cellY+cellSize)87+ }88+ if mask.has(boundaryBottom) {89+ pdf.Line(cellX, cellY+cellSize, cellX+cellSize, cellY+cellSize)90+ }91+92+ if data.Givens[y][x] == 0 {93+ continue94+ }95+ drawRippleEffectGiven(pdf, cellX, cellY, cellSize, strconv.Itoa(data.Givens[y][x]))96+ }97+ }98+99+ ruleY := pdfexport.InstructionY(startY+blockH, pageH, 2)100+ pdfexport.SetInstructionStyle(pdf)101+ pdf.SetXY(area.X, ruleY)102+ pdf.CellFormat(103+ area.W,104+ pdfexport.InstructionLineHMM,105+ "Each cage uses 1..n once; equal digits must be at least their value apart in rows and columns.",106+ "",107+ 0,108+ "C",109+ false,110+ 0,111+ "",112+ )113+}114+115+func drawRippleEffectGiven(pdf *fpdf.Fpdf, x, y, cellSize float64, text string) {116+ fontSize := pdfexport.ClampStandardCellFontSize(pdfexport.StandardCellFontSize(cellSize, 0.58))117+ pdf.SetTextColor(pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray)118+ pdf.SetFont(pdfexport.SansFontFamily, "B", fontSize)119+ lineH := fontSize * 0.9120+ pdf.SetXY(x, y+(cellSize-lineH)/2)121+ pdf.CellFormat(cellSize, lineH, text, "", 0, "C", false, 0, "")122+}123+124+func init() {125+ game.RegisterPrintAdapter(printAdapter{})126+}@@ -0,0 +1,21 @@
1+# Ripple Effect2+3+Place digits into irregular cages so each cage contains `1..n` exactly once, and matching digits stay outside each other’s ripple distance.4+5+## Modes6+7+| Mode | Size | Notes |8+|------|------|-------|9+| Mini 5x5 | 5x5 | Compact intro board |10+| Easy 6x6 | 6x6 | Gentle spacing logic |11+| Medium 7x7 | 7x7 | Balanced cages and ripples |12+| Hard 8x8 | 8x8 | Denser interactions |13+| Expert 9x9 | 9x9 | Wider reasoning chains |14+15+## CLI16+17+```bash18+puzzletea new ripple-effect19+puzzletea new ripple "Medium 7x7"20+puzzletea new ripple-effect "Hard 8x8" --with-seed sample-seed21+```@@ -0,0 +1,278 @@
1+package rippleeffect2+3+import (4+ "fmt"5+ "math/rand/v2"6+)7+8+type Puzzle struct {9+ Width int10+ Height int11+ Cages []Cage12+ Givens grid13+ Solution grid14+}15+16+func GeneratePuzzle(width, height, maxCage int, givenRatio float64) (Puzzle, error) {17+ rng := rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))18+ return GeneratePuzzleSeeded(width, height, maxCage, givenRatio, rng)19+}20+21+func GeneratePuzzleSeeded(width, height, maxCage int, givenRatio float64, rng *rand.Rand) (Puzzle, error) {22+ const maxAttempts = 2423+ for attempt := 0; attempt < maxAttempts; attempt++ {24+ cages := generateCages(width, height, maxCage, rng)25+ geo, err := buildGeometry(width, height, cages)26+ if err != nil {27+ continue28+ }29+30+ solution, ok := generateSolution(geo, rng)31+ if !ok {32+ continue33+ }34+35+ givens := removeClues(geo, solution, givenRatio, rng)36+ if countSolutions(geo, givens, 2) != 1 {37+ continue38+ }39+40+ return Puzzle{41+ Width: width,42+ Height: height,43+ Cages: cages,44+ Givens: givens,45+ Solution: solution,46+ }, nil47+ }48+49+ return Puzzle{}, fmt.Errorf("generate ripple effect puzzle %dx%d: exceeded retry budget", width, height)50+}51+52+func generateCages(width, height, maxCage int, rng *rand.Rand) []Cage {53+ assigned := make([][]bool, height)54+ for y := range height {55+ assigned[y] = make([]bool, width)56+ }57+58+ cages := make([]Cage, 0, width*height/2)59+ for {60+ start, ok := firstUnassigned(assigned)61+ if !ok {62+ return cages63+ }64+65+ target := chooseCageSize(maxCage, remainingUnassigned(assigned), rng)66+ shape := growCageShape(start, target, assigned, rng)67+ for _, cell := range shape {68+ assigned[cell.y][cell.x] = true69+ }70+71+ cells := make([]Cell, len(shape))72+ for i, cell := range shape {73+ cells[i] = Cell{X: cell.x, Y: cell.y}74+ }75+ cages = append(cages, Cage{76+ ID: len(cages),77+ Size: len(cells),78+ Cells: cells,79+ })80+ }81+}82+83+func firstUnassigned(assigned [][]bool) (point, bool) {84+ for y := range len(assigned) {85+ for x := range len(assigned[y]) {86+ if !assigned[y][x] {87+ return point{x: x, y: y}, true88+ }89+ }90+ }91+ return point{}, false92+}93+94+func remainingUnassigned(assigned [][]bool) int {95+ count := 096+ for y := range len(assigned) {97+ for x := range len(assigned[y]) {98+ if !assigned[y][x] {99+ count++100+ }101+ }102+ }103+ return count104+}105+106+func chooseCageSize(maxCage, remaining int, rng *rand.Rand) int {107+ if remaining < maxCage {108+ maxCage = remaining109+ }110+ weights := []int{0, 2, 4, 5, 4, 3, 2, 1, 1, 1}111+ totalWeight := 0112+ for size := 1; size <= maxCage; size++ {113+ totalWeight += weights[min(size, len(weights)-1)]114+ }115+ if totalWeight == 0 {116+ return 1117+ }118+119+ pick := rng.IntN(totalWeight)120+ for size := 1; size <= maxCage; size++ {121+ pick -= weights[min(size, len(weights)-1)]122+ if pick < 0 {123+ return size124+ }125+ }126+ return 1127+}128+129+func growCageShape(start point, target int, assigned [][]bool, rng *rand.Rand) []point {130+ shape := []point{start}131+ inShape := map[point]struct{}{start: {}}132+ height := len(assigned)133+ width := len(assigned[0])134+135+ for len(shape) < target {136+ frontier := make([]point, 0, 4)137+ seen := make(map[point]struct{})138+ for _, cell := range shape {139+ for _, next := range orthogonalNeighbors(cell, width, height) {140+ if assigned[next.y][next.x] {141+ continue142+ }143+ if _, ok := inShape[next]; ok {144+ continue145+ }146+ if _, ok := seen[next]; ok {147+ continue148+ }149+ frontier = append(frontier, next)150+ seen[next] = struct{}{}151+ }152+ }153+ if len(frontier) == 0 {154+ break155+ }156+ next := frontier[rng.IntN(len(frontier))]157+ shape = append(shape, next)158+ inShape[next] = struct{}{}159+ }160+161+ return shape162+}163+164+func generateSolution(geo *geometry, rng *rand.Rand) (grid, bool) {165+ state := newGrid(geo.width, geo.height)166+ if fillSolution(geo, state, rng) {167+ return state, true168+ }169+ return nil, false170+}171+172+func fillSolution(geo *geometry, state grid, rng *rand.Rand) bool {173+ cell, candidates, ok := chooseNextFillCell(geo, state, rng)174+ if !ok {175+ return true176+ }177+ if len(candidates) == 0 {178+ return false179+ }180+181+ for _, value := range candidates {182+ state[cell.y][cell.x] = value183+ if fillSolution(geo, state, rng) {184+ return true185+ }186+ state[cell.y][cell.x] = 0187+ }188+189+ return false190+}191+192+func chooseNextFillCell(geo *geometry, state grid, rng *rand.Rand) (point, []int, bool) {193+ best := point{}194+ bestCandidates := []int(nil)195+ bestCount := 10196+ ties := 0197+198+ for y := range geo.height {199+ for x := range geo.width {200+ if state[y][x] != 0 {201+ continue202+ }203+ cell := point{x: x, y: y}204+ candidates := candidatesForCell(geo, state, cell)205+ if len(candidates) < bestCount {206+ best = cell207+ bestCandidates = candidates208+ bestCount = len(candidates)209+ ties = 1210+ continue211+ }212+ if len(candidates) == bestCount {213+ ties++214+ if rng.IntN(ties) == 0 {215+ best = cell216+ bestCandidates = candidates217+ }218+ }219+ }220+ }221+222+ if bestCount == 10 {223+ return point{}, nil, false224+ }225+226+ rng.Shuffle(len(bestCandidates), func(i, j int) {227+ bestCandidates[i], bestCandidates[j] = bestCandidates[j], bestCandidates[i]228+ })229+ return best, bestCandidates, true230+}231+232+func removeClues(geo *geometry, solution grid, givenRatio float64, rng *rand.Rand) grid {233+ givens := cloneGrid(solution)234+ totalCells := geo.width * geo.height235+ target := int(float64(totalCells) * givenRatio)236+ if target < len(geo.cages) {237+ target = len(geo.cages)238+ }239+240+ remainingByCage := make([]int, len(geo.cages))241+ for cageIdx, cells := range geo.cageCells {242+ remainingByCage[cageIdx] = len(cells)243+ }244+245+ cells := make([]point, 0, totalCells)246+ for y := range geo.height {247+ for x := range geo.width {248+ cells = append(cells, point{x: x, y: y})249+ }250+ }251+ rng.Shuffle(len(cells), func(i, j int) {252+ cells[i], cells[j] = cells[j], cells[i]253+ })254+255+ remaining := totalCells256+ for _, cell := range cells {257+ if remaining <= target {258+ break259+ }260+261+ cageIdx := geo.cageGrid[cell.y][cell.x]262+ if remainingByCage[cageIdx] <= 1 {263+ continue264+ }265+266+ value := givens[cell.y][cell.x]267+ givens[cell.y][cell.x] = 0268+ remainingByCage[cageIdx]--269+ if countSolutions(geo, givens, 2) != 1 {270+ givens[cell.y][cell.x] = value271+ remainingByCage[cageIdx]++272+ continue273+ }274+ remaining--275+ }276+277+ return givens278+}@@ -0,0 +1,184 @@
1+package rippleeffect2+3+import "fmt"4+5+type Cell struct {6+ X int `json:"x"`7+ Y int `json:"y"`8+}9+10+type Cage struct {11+ ID int `json:"id"`12+ Size int `json:"size"`13+ Cells []Cell `json:"cells"`14+}15+16+type boundaryMask uint817+18+const (19+ boundaryTop boundaryMask = 1 << iota20+ boundaryRight21+ boundaryBottom22+ boundaryLeft23+)24+25+func (m boundaryMask) has(flag boundaryMask) bool {26+ return m&flag != 027+}28+29+type geometry struct {30+ width int31+ height int32+ cages []Cage33+ cageGrid [][]int34+ cageCells [][]point35+ cageSizes []int36+ boundaries [][]boundaryMask37+}38+39+func buildGeometry(width, height int, cages []Cage) (*geometry, error) {40+ if width <= 0 || height <= 0 {41+ return nil, fmt.Errorf("invalid ripple effect size %dx%d", width, height)42+ }43+ if len(cages) == 0 {44+ return nil, fmt.Errorf("ripple effect requires at least one cage")45+ }46+47+ cageGrid := make([][]int, height)48+ for y := range height {49+ cageGrid[y] = make([]int, width)50+ for x := range width {51+ cageGrid[y][x] = -152+ }53+ }54+55+ idSeen := make(map[int]struct{}, len(cages))56+ cageCells := make([][]point, len(cages))57+ cageSizes := make([]int, len(cages))58+ for cageIdx, cage := range cages {59+ if _, exists := idSeen[cage.ID]; exists {60+ return nil, fmt.Errorf("duplicate cage id %d", cage.ID)61+ }62+ idSeen[cage.ID] = struct{}{}63+64+ size := cage.Size65+ if size == 0 {66+ size = len(cage.Cells)67+ cages[cageIdx].Size = size68+ }69+ if size != len(cage.Cells) {70+ return nil, fmt.Errorf("cage %d size %d does not match %d cells", cage.ID, cage.Size, len(cage.Cells))71+ }72+ if size <= 0 || size > 9 {73+ return nil, fmt.Errorf("cage %d has invalid size %d", cage.ID, size)74+ }75+76+ cells := make([]point, 0, len(cage.Cells))77+ for _, cell := range cage.Cells {78+ if cell.X < 0 || cell.X >= width || cell.Y < 0 || cell.Y >= height {79+ return nil, fmt.Errorf("cage %d has out-of-bounds cell (%d,%d)", cage.ID, cell.X, cell.Y)80+ }81+ if cageGrid[cell.Y][cell.X] >= 0 {82+ return nil, fmt.Errorf("cell (%d,%d) belongs to multiple cages", cell.X, cell.Y)83+ }84+ cageGrid[cell.Y][cell.X] = cageIdx85+ cells = append(cells, point{x: cell.X, y: cell.Y})86+ }87+ if !cellsConnected(cells) {88+ return nil, fmt.Errorf("cage %d is not connected", cage.ID)89+ }90+91+ cageCells[cageIdx] = cells92+ cageSizes[cageIdx] = size93+ }94+95+ for y := range height {96+ for x := range width {97+ if cageGrid[y][x] < 0 {98+ return nil, fmt.Errorf("cell (%d,%d) is not assigned to any cage", x, y)99+ }100+ }101+ }102+103+ return &geometry{104+ width: width,105+ height: height,106+ cages: cages,107+ cageGrid: cageGrid,108+ cageCells: cageCells,109+ cageSizes: cageSizes,110+ boundaries: buildBoundaryMasks(cageGrid),111+ }, nil112+}113+114+func cellsConnected(cells []point) bool {115+ if len(cells) <= 1 {116+ return true117+ }118+119+ cellSet := make(map[point]struct{}, len(cells))120+ for _, cell := range cells {121+ cellSet[cell] = struct{}{}122+ }123+124+ queue := []point{cells[0]}125+ visited := map[point]struct{}{cells[0]: {}}126+ for len(queue) > 0 {127+ curr := queue[0]128+ queue = queue[1:]129+ for _, next := range orthogonalNeighbors(curr, 1<<30, 1<<30) {130+ if _, ok := cellSet[next]; !ok {131+ continue132+ }133+ if _, ok := visited[next]; ok {134+ continue135+ }136+ visited[next] = struct{}{}137+ queue = append(queue, next)138+ }139+ }140+141+ return len(visited) == len(cells)142+}143+144+func buildBoundaryMasks(cageGrid [][]int) [][]boundaryMask {145+ height := len(cageGrid)146+ width := len(cageGrid[0])147+ masks := make([][]boundaryMask, height)148+ for y := range height {149+ masks[y] = make([]boundaryMask, width)150+ for x := range width {151+ cage := cageGrid[y][x]152+ if y == 0 || cageGrid[y-1][x] != cage {153+ masks[y][x] |= boundaryTop154+ }155+ if x == width-1 || cageGrid[y][x+1] != cage {156+ masks[y][x] |= boundaryRight157+ }158+ if y == height-1 || cageGrid[y+1][x] != cage {159+ masks[y][x] |= boundaryBottom160+ }161+ if x == 0 || cageGrid[y][x-1] != cage {162+ masks[y][x] |= boundaryLeft163+ }164+ }165+ }166+ return masks167+}168+169+func orthogonalNeighbors(p point, width, height int) []point {170+ neighbors := make([]point, 0, 4)171+ if p.x > 0 {172+ neighbors = append(neighbors, point{x: p.x - 1, y: p.y})173+ }174+ if p.x+1 < width {175+ neighbors = append(neighbors, point{x: p.x + 1, y: p.y})176+ }177+ if p.y > 0 {178+ neighbors = append(neighbors, point{x: p.x, y: p.y - 1})179+ }180+ if p.y+1 < height {181+ neighbors = append(neighbors, point{x: p.x, y: p.y + 1})182+ }183+ return neighbors184+}@@ -0,0 +1,95 @@
1+package rippleeffect2+3+import (4+ "errors"5+ "strconv"6+ "strings"7+)8+9+var errInvalidGridEncoding = errors.New("invalid ripple effect grid encoding")10+11+type point struct {12+ x int13+ y int14+}15+16+type grid [][]int17+18+func newGrid(width, height int) grid {19+ cells := make(grid, height)20+ for y := range height {21+ cells[y] = make([]int, width)22+ }23+ return cells24+}25+26+func cloneGrid(src grid) grid {27+ if src == nil {28+ return nil29+ }30+31+ height := len(src)32+ width := 033+ if height > 0 {34+ width = len(src[0])35+ }36+37+ dst := newGrid(width, height)38+ for y := range height {39+ copy(dst[y], src[y])40+ }41+ return dst42+}43+44+func parseGrid(encoded string, width, height int) (grid, error) {45+ cells := newGrid(width, height)46+ rows := strings.Split(strings.TrimSpace(encoded), "\n")47+ for y := range height {48+ if y >= len(rows) {49+ continue50+ }51+ fields := strings.Fields(rows[y])52+ if len(fields) == 0 {53+ fields = splitCompactFields(rows[y])54+ }55+ if len(fields) != width {56+ return nil, errInvalidGridEncoding57+ }58+ for x := range width {59+ if fields[x] == "." {60+ continue61+ }62+ value, err := strconv.Atoi(fields[x])63+ if err != nil {64+ return nil, errInvalidGridEncoding65+ }66+ cells[y][x] = value67+ }68+ }69+70+ return cells, nil71+}72+73+func encodeGrid(g grid) string {74+ rows := make([]string, 0, len(g))75+ for _, row := range g {76+ fields := make([]string, len(row))77+ for x, value := range row {78+ if value == 0 {79+ fields[x] = "."80+ continue81+ }82+ fields[x] = strconv.Itoa(value)83+ }84+ rows = append(rows, strings.Join(fields, " "))85+ }86+ return strings.Join(rows, "\n")87+}88+89+func splitCompactFields(row string) []string {90+ fields := make([]string, 0, len(row))91+ for _, r := range row {92+ fields = append(fields, string(r))93+ }94+ return fields95+}@@ -0,0 +1,27 @@
1+# Ripple Effect2+3+Fill each cage with the digits `1` through the cage size, then keep equal digits far enough apart so their ripples do not collide.4+5+## Rules6+7+- Every cage of size `n` must contain the digits `1..n` exactly once.8+- Equal digits cannot appear too close in the same row or column.9+- A digit `n` blocks the next `n-1` cells in each orthogonal direction from also being `n`.10+- Given cells are locked. The puzzle is solved when every cell is filled and no cage or ripple rule is broken.11+12+## Controls13+14+| Key | Action |15+|-----|--------|16+| `Arrows` / `wasd` / `hjkl` | Move cursor |17+| `1`-`9` | Place number |18+| `Backspace` / `Delete` | Clear cell |19+| `Ctrl+H` | Toggle help bar |20+| `Ctrl+R` | Reset puzzle |21+| `Ctrl+N` | Return to main menu |22+23+## Tips24+25+- Finish the smallest cages first; they have the fewest possibilities.26+- When a digit is placed, count outward in the row and column to eliminate matching values nearby.27+- If a cage already contains a digit, that value cannot appear again in the same cage even if the ripple rule would allow it.@@ -0,0 +1,43 @@
1+package rippleeffect2+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+ Clear key.Binding12+}13+14+var DefaultKeyMap = KeyMap{15+ CursorKeyMap: game.DefaultCursorKeyMap,16+ FillValue: key.NewBinding(17+ key.WithKeys("1", "2", "3", "4", "5", "6", "7", "8", "9"),18+ key.WithHelp("1-9", "Fill"),19+ ),20+ Clear: key.NewBinding(21+ key.WithKeys("backspace", "delete"),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 < m.height-1)29+ m.keys.Left.SetEnabled(m.cursor.X > 0)30+ m.keys.Right.SetEnabled(m.cursor.X < m.width-1)31+32+ locked := m.givens[m.cursor.Y][m.cursor.X] != 033+ enabled := !locked && !m.solved34+ m.keys.FillValue.SetEnabled(enabled)35+ m.keys.Clear.SetEnabled(enabled)36+}37+38+func (m Model) GetFullHelp() [][]key.Binding {39+ return [][]key.Binding{40+ {m.keys.Up, m.keys.Down, m.keys.Left, m.keys.Right},41+ {m.keys.FillValue, m.keys.Clear},42+ }43+}@@ -0,0 +1,439 @@
1+package rippleeffect2+3+import (4+ "image/color"5+ "math/rand/v2"6+ "strings"7+ "testing"8+9+ "charm.land/lipgloss/v2"10+ "github.com/FelineStateMachine/puzzletea/game"11+ "github.com/FelineStateMachine/puzzletea/theme"12+)13+14+func sampleCages() []Cage {15+ return []Cage{16+ {ID: 0, Size: 3, Cells: []Cell{{0, 0}, {1, 0}, {2, 0}}},17+ {ID: 1, Size: 3, Cells: []Cell{{0, 1}, {1, 1}, {2, 1}}},18+ {ID: 2, Size: 3, Cells: []Cell{{0, 2}, {1, 2}, {2, 2}}},19+ }20+}21+22+func sampleSolution() grid {23+ return grid{24+ {1, 2, 3},25+ {2, 3, 1},26+ {3, 1, 2},27+ }28+}29+30+func TestBuildGeometryBoundaries(t *testing.T) {31+ geo, err := buildGeometry(3, 3, sampleCages())32+ if err != nil {33+ t.Fatal(err)34+ }35+36+ if !geo.boundaries[0][0].has(boundaryTop) || !geo.boundaries[0][0].has(boundaryLeft) {37+ t.Fatal("expected top-left cell to have outer top/left boundaries")38+ }39+ if geo.boundaries[0][0].has(boundaryRight) {40+ t.Fatal("expected shared cage edge on the right to be open")41+ }42+ if !geo.boundaries[1][1].has(boundaryTop) {43+ t.Fatal("expected cage boundary between first and second rows")44+ }45+ if !geo.boundaries[2][2].has(boundaryBottom) || !geo.boundaries[2][2].has(boundaryRight) {46+ t.Fatal("expected bottom-right outer boundaries")47+ }48+}49+50+func TestValidateGridState(t *testing.T) {51+ geo, err := buildGeometry(3, 3, sampleCages())52+ if err != nil {53+ t.Fatal(err)54+ }55+56+ tests := []struct {57+ name string58+ grid grid59+ solved bool60+ }{61+ {62+ name: "valid solved board",63+ grid: sampleSolution(),64+ solved: true,65+ },66+ {67+ name: "duplicate within cage",68+ grid: grid{69+ {1, 1, 3},70+ {2, 3, 1},71+ {3, 1, 2},72+ },73+ solved: false,74+ },75+ {76+ name: "ripple conflict",77+ grid: grid{78+ {2, 1, 3},79+ {2, 3, 1},80+ {3, 1, 2},81+ },82+ solved: false,83+ },84+ {85+ name: "incomplete board",86+ grid: grid{87+ {1, 2, 3},88+ {2, 0, 1},89+ {3, 1, 2},90+ },91+ solved: false,92+ },93+ }94+95+ for _, tt := range tests {96+ t.Run(tt.name, func(t *testing.T) {97+ result := validateGridState(tt.grid, geo)98+ if result.solved != tt.solved {99+ t.Fatalf("solved = %v, want %v", result.solved, tt.solved)100+ }101+ })102+ }103+}104+105+func TestCountSolutions(t *testing.T) {106+ geo, err := buildGeometry(3, 3, sampleCages())107+ if err != nil {108+ t.Fatal(err)109+ }110+111+ tests := []struct {112+ name string113+ givens grid114+ want int115+ }{116+ {117+ name: "unique solution",118+ givens: grid{119+ {1, 2, 3},120+ {2, 3, 1},121+ {3, 1, 2},122+ },123+ want: 1,124+ },125+ {126+ name: "ambiguous puzzle",127+ givens: grid{128+ {0, 0, 0},129+ {0, 0, 0},130+ {0, 0, 0},131+ },132+ want: 2,133+ },134+ {135+ name: "impossible puzzle",136+ givens: grid{137+ {2, 2, 3},138+ {0, 0, 0},139+ {0, 0, 0},140+ },141+ want: 0,142+ },143+ }144+145+ for _, tt := range tests {146+ t.Run(tt.name, func(t *testing.T) {147+ got := countSolutions(geo, tt.givens, 2)148+ if got != tt.want {149+ t.Fatalf("countSolutions() = %d, want %d", got, tt.want)150+ }151+ })152+ }153+}154+155+func TestGeneratePuzzleSeeded(t *testing.T) {156+ mode := Modes[0].(Mode)157+ rngA := rand.New(rand.NewPCG(11, 22))158+ rngB := rand.New(rand.NewPCG(11, 22))159+160+ a, err := GeneratePuzzleSeeded(mode.Size, mode.Size, mode.MaxCage, mode.GivenRatio, rngA)161+ if err != nil {162+ t.Fatal(err)163+ }164+ b, err := GeneratePuzzleSeeded(mode.Size, mode.Size, mode.MaxCage, mode.GivenRatio, rngB)165+ if err != nil {166+ t.Fatal(err)167+ }168+169+ if encodeGrid(a.Givens) != encodeGrid(b.Givens) {170+ t.Fatalf("seeded givens differ:\n%s\n!=\n%s", encodeGrid(a.Givens), encodeGrid(b.Givens))171+ }172+ if len(a.Cages) != len(b.Cages) {173+ t.Fatalf("seeded cage count mismatch: %d != %d", len(a.Cages), len(b.Cages))174+ }175+ geo, err := buildGeometry(a.Width, a.Height, a.Cages)176+ if err != nil {177+ t.Fatal(err)178+ }179+ if !validateGridState(a.Solution, geo).solved {180+ t.Fatal("generated solution is not solved")181+ }182+ if got := countSolutions(geo, a.Givens, 2); got != 1 {183+ t.Fatalf("generated puzzle solutions = %d, want 1", got)184+ }185+}186+187+func TestSaveRoundTrip(t *testing.T) {188+ puzzle := Puzzle{189+ Width: 3,190+ Height: 3,191+ Cages: sampleCages(),192+ Givens: grid{{1, 0, 3}, {0, 3, 0}, {3, 0, 2}},193+ Solution: sampleSolution(),194+ }195+ g, err := New(NewMode("Test", "desc", 3, 3, 1), puzzle)196+ if err != nil {197+ t.Fatal(err)198+ }199+ model := g.(Model)200+ model.cursor = pointCursor(1, 0)201+ model.setCell(2)202+203+ save, err := model.GetSave()204+ if err != nil {205+ t.Fatal(err)206+ }207+ loaded, err := ImportModel(save)208+ if err != nil {209+ t.Fatal(err)210+ }211+212+ if got, want := loaded.grid[0][1], 2; got != want {213+ t.Fatalf("loaded grid[0][1] = %d, want %d", got, want)214+ }215+ if got, want := loaded.geo.cageGrid[2][2], 2; got != want {216+ t.Fatalf("loaded cage grid[2][2] = %d, want %d", got, want)217+ }218+}219+220+func TestChooseVisualKindPriority(t *testing.T) {221+ if got := chooseVisualKind(true, false, true, true, true, true); got != visualConflictCursor {222+ t.Fatalf("conflicted cursor should win, got %v", got)223+ }224+ if got := chooseVisualKind(true, true, false, true, true, true); got != visualSolved {225+ t.Fatalf("solved should beat cursor, got %v", got)226+ }227+ if got := chooseVisualKind(true, false, false, true, true, true); got != visualCursor {228+ t.Fatalf("cursor should beat cage highlight, got %v", got)229+ }230+ if got := chooseVisualKind(false, false, false, true, true, true); got != visualCompleted {231+ t.Fatalf("completed should beat transient cage highlight, got %v", got)232+ }233+}234+235+func TestCursorText(t *testing.T) {236+ if got, want := cursorText(0), game.CursorLeft+"·"+game.CursorRight; got != want {237+ t.Fatalf("cursorText(0) = %q, want %q", got, want)238+ }239+ if got, want := cursorText(3), game.CursorLeft+"3"+game.CursorRight; got != want {240+ t.Fatalf("cursorText(3) = %q, want %q", got, want)241+ }242+}243+244+func TestCellViewConflictedCursorIsDistinct(t *testing.T) {245+ geo, err := buildGeometry(3, 3, sampleCages())246+ if err != nil {247+ t.Fatal(err)248+ }249+250+ m := Model{251+ width: 3,252+ height: 3,253+ grid: sampleSolution(),254+ givens: grid{{0, 0, 0}, {0, 0, 0}, {0, 0, 0}},255+ conflicts: newConflictGrid(3, 3),256+ cursor: pointCursor(1, 1),257+ geo: geo,258+ }259+ m.conflicts[1][1] = true260+261+ conflict := cellView(m, 0, 0, 0, nil)262+ cursorConflict := cellView(m, 1, 1, 0, nil)263+264+ if conflict == cursorConflict {265+ t.Fatal("conflicted cursor should render differently from plain conflicted cell")266+ }267+ if !strings.Contains(cursorConflict, game.CursorLeft+"3"+game.CursorRight) {268+ t.Fatal("conflicted cursor should include cursor markers")269+ }270+}271+272+func TestGridViewUsesUniformRowsForBronzeBirchState(t *testing.T) {273+ model, err := ImportModel([]byte(`{"width":5,"height":5,"state":". 2 1 . 3\n2 1 1 1 .\n1 1 3 . .\n1 . 1 3 2\n2 3 2 1 1","givens":". 2 1 . 3\n2 1 1 1 .\n1 1 3 . .\n1 . 1 3 2\n2 3 2 1 1","cages":[{"id":0,"size":3,"cells":[{"x":0,"y":0},{"x":1,"y":0},{"x":1,"y":1}]},{"id":1,"size":2,"cells":[{"x":2,"y":0},{"x":3,"y":0}]},{"id":2,"size":3,"cells":[{"x":4,"y":0},{"x":4,"y":1},{"x":4,"y":2}]},{"id":3,"size":2,"cells":[{"x":0,"y":1},{"x":0,"y":2}]},{"id":4,"size":1,"cells":[{"x":2,"y":1}]},{"id":5,"size":3,"cells":[{"x":3,"y":1},{"x":3,"y":2},{"x":3,"y":3}]},{"id":6,"size":3,"cells":[{"x":1,"y":2},{"x":1,"y":3},{"x":2,"y":2}]},{"id":7,"size":2,"cells":[{"x":0,"y":3},{"x":0,"y":4}]},{"id":8,"size":3,"cells":[{"x":2,"y":3},{"x":2,"y":4},{"x":1,"y":4}]},{"id":9,"size":2,"cells":[{"x":4,"y":3},{"x":4,"y":4}]},{"id":10,"size":1,"cells":[{"x":3,"y":4}]}],"mode_title":"bronze-birch"}`))274+ if err != nil {275+ t.Fatal(err)276+ }277+278+ view := gridView(*model)279+ lines := strings.Split(view, "\n")280+ if got, want := len(lines), model.height*2+1; got != want {281+ t.Fatalf("grid line count = %d, want %d", got, want)282+ }283+284+ wantWidth := lipgloss.Width(lines[0])285+ for i, line := range lines {286+ if width := lipgloss.Width(line); width != wantWidth {287+ t.Fatalf("line %d width = %d, want %d", i, width, wantWidth)288+ }289+ }290+}291+292+func TestCompletedCageBackgrounds(t *testing.T) {293+ geo, err := buildGeometry(3, 3, sampleCages())294+ if err != nil {295+ t.Fatal(err)296+ }297+298+ m := Model{299+ width: 3,300+ height: 3,301+ geo: geo,302+ grid: sampleSolution(),303+ givens: newGrid(3, 3),304+ conflicts: newConflictGrid(3, 3),305+ }306+307+ completed := completedCageBackgrounds(m)308+ if len(completed) != len(sampleCages()) {309+ t.Fatalf("completed cages = %d, want %d", len(completed), len(sampleCages()))310+ }311+312+ m.conflicts[0][0] = true313+ completed = completedCageBackgrounds(m)314+ if _, ok := completed[0]; ok {315+ t.Fatal("conflicted cage should not get completed background")316+ }317+}318+319+func TestSolvedBridgeBackgroundsUseSolvedColor(t *testing.T) {320+ geo, err := buildGeometry(3, 3, sampleCages())321+ if err != nil {322+ t.Fatal(err)323+ }324+325+ m := Model{326+ width: 3,327+ height: 3,328+ geo: geo,329+ solved: true,330+ }331+332+ backgrounds := solvedBridgeBackgrounds(m)333+ if len(backgrounds) != len(sampleCages()) {334+ t.Fatalf("solved bridge backgrounds = %d, want %d", len(backgrounds), len(sampleCages()))335+ }336+337+ want := theme.Current().SuccessBG338+ for cageIdx, bg := range backgrounds {339+ if !sameColor(bg, want) {340+ t.Fatalf("cage %d bridge background does not use solved color", cageIdx)341+ }342+ }343+}344+345+func TestCompletedGapBackgrounds(t *testing.T) {346+ geo, err := buildGeometry(3, 3, sampleCages())347+ if err != nil {348+ t.Fatal(err)349+ }350+351+ bg := color.NRGBA{R: 10, G: 20, B: 30, A: 255}352+ completed := map[int]color.Color{0: bg}353+354+ if got := verticalGapBackground(geo, completed, 1, 0); !sameColor(got, bg) {355+ t.Fatal("expected vertical interior gap to inherit completed cage background")356+ }357+ if got := horizontalGapBackground(geo, completed, 1, 1); got != nil {358+ t.Fatal("expected cage boundary row to keep nil background")359+ }360+361+ squareGeo, err := buildGeometry(2, 2, []Cage{362+ {ID: 0, Size: 4, Cells: []Cell{{0, 0}, {1, 0}, {0, 1}, {1, 1}}},363+ })364+ if err != nil {365+ t.Fatal(err)366+ }367+ completed = map[int]color.Color{0: bg}368+ if got := junctionGapBackground(squareGeo, completed, 1, 1); !sameColor(got, bg) {369+ t.Fatal("expected empty junction inside completed cage to inherit background")370+ }371+}372+373+func TestConflictBridgeBackgrounds(t *testing.T) {374+ geo, err := buildGeometry(3, 3, sampleCages())375+ if err != nil {376+ t.Fatal(err)377+ }378+379+ m := Model{380+ width: 3,381+ height: 3,382+ grid: sampleSolution(),383+ givens: grid{{0, 0, 0}, {0, 0, 0}, {0, 0, 0}},384+ conflicts: newConflictGrid(3, 3),385+ geo: geo,386+ }387+ m.conflicts[0][0] = true388+389+ bridge := bridgeBackgrounds(m, nil)390+ if got := verticalGapBackground(geo, bridge, 1, 0); !sameColor(got, game.ConflictBG()) {391+ t.Fatal("expected conflict color to bridge vertical gap")392+ }393+}394+395+func TestActiveCageBridgeBackgrounds(t *testing.T) {396+ geo, err := buildGeometry(3, 3, sampleCages())397+ if err != nil {398+ t.Fatal(err)399+ }400+401+ m := Model{402+ width: 3,403+ height: 3,404+ grid: newGrid(3, 3),405+ givens: newGrid(3, 3),406+ conflicts: newConflictGrid(3, 3),407+ cursor: pointCursor(1, 0),408+ geo: geo,409+ }410+411+ bridge := bridgeBackgrounds(m, nil)412+ want := theme.Current().SelectionBG413+ if got := verticalGapBackground(geo, bridge, 1, 0); !sameColor(got, want) {414+ t.Fatal("expected active cage highlight to bridge vertical gap")415+ }416+ if got := horizontalGapBackground(geo, bridge, 1, 1); got != nil {417+ t.Fatal("expected cage boundary row to keep nil background for hover highlight")418+ }419+}420+421+func sameColor(a, b color.Color) bool {422+ if a == nil || b == nil {423+ return a == b424+ }425+ ar, ag, ab, aa := a.RGBA()426+ br, bg, bb, ba := b.RGBA()427+ return ar == br && ag == bg && ab == bb && aa == ba428+}429+430+func TestImportRejectsInvalidGivens(t *testing.T) {431+ _, err := ImportModel([]byte(`{"width":3,"height":3,"state":"1 2 3\n2 3 1\n3 1 2","givens":"1 2 3\n2 4 1\n3 1 2","cages":[{"id":0,"size":3,"cells":[{"x":0,"y":0},{"x":1,"y":0},{"x":2,"y":0}]},{"id":1,"size":3,"cells":[{"x":0,"y":1},{"x":1,"y":1},{"x":2,"y":1}]},{"id":2,"size":3,"cells":[{"x":0,"y":2},{"x":1,"y":2},{"x":2,"y":2}]}],"mode_title":"Test"}`))432+ if err == nil {433+ t.Fatal("expected import error")434+ }435+}436+437+func pointCursor(x, y int) game.Cursor {438+ return game.Cursor{X: x, Y: y}439+}@@ -0,0 +1,191 @@
1+package rippleeffect2+3+import "math"4+5+type validationResult struct {6+ solved bool7+ conflicts [][]bool8+}9+10+func validateGridState(state grid, geo *geometry) validationResult {11+ conflicts := newConflictGrid(geo.width, geo.height)12+ complete := true13+14+ for cageIdx, cells := range geo.cageCells {15+ size := geo.cageSizes[cageIdx]16+ seen := make(map[int]point, size)17+ for _, cell := range cells {18+ value := state[cell.y][cell.x]19+ if value == 0 {20+ complete = false21+ continue22+ }23+ if value < 1 || value > size {24+ conflicts[cell.y][cell.x] = true25+ continue26+ }27+ if prior, exists := seen[value]; exists {28+ conflicts[cell.y][cell.x] = true29+ conflicts[prior.y][prior.x] = true30+ continue31+ }32+ seen[value] = cell33+ }34+ }35+36+ for y := range geo.height {37+ for x := range geo.width {38+ value := state[y][x]39+ if value == 0 {40+ continue41+ }42+ for step := 1; step < value; step++ {43+ if x+step < geo.width && state[y][x+step] == value {44+ conflicts[y][x] = true45+ conflicts[y][x+step] = true46+ }47+ if y+step < geo.height && state[y+step][x] == value {48+ conflicts[y][x] = true49+ conflicts[y+step][x] = true50+ }51+ }52+ }53+ }54+55+ return validationResult{56+ solved: complete && !hasConflicts(conflicts),57+ conflicts: conflicts,58+ }59+}60+61+func newConflictGrid(width, height int) [][]bool {62+ conflicts := make([][]bool, height)63+ for y := range height {64+ conflicts[y] = make([]bool, width)65+ }66+ return conflicts67+}68+69+func hasConflicts(conflicts [][]bool) bool {70+ for y := range len(conflicts) {71+ for x := range len(conflicts[y]) {72+ if conflicts[y][x] {73+ return true74+ }75+ }76+ }77+ return false78+}79+80+func countSolutions(geo *geometry, givens grid, limit int) int {81+ working := cloneGrid(givens)82+ return searchSolutions(geo, working, limit)83+}84+85+func searchSolutions(geo *geometry, state grid, limit int) int {86+ if limit <= 0 {87+ return 088+ }89+90+ cell, candidates, ok := chooseNextCell(geo, state)91+ if !ok {92+ if validateGridState(state, geo).solved {93+ return 194+ }95+ return 096+ }97+ if len(candidates) == 0 {98+ return 099+ }100+101+ total := 0102+ for _, value := range candidates {103+ state[cell.y][cell.x] = value104+ total += searchSolutions(geo, state, limit-total)105+ if total >= limit {106+ state[cell.y][cell.x] = 0107+ return total108+ }109+ state[cell.y][cell.x] = 0110+ }111+112+ return total113+}114+115+func chooseNextCell(geo *geometry, state grid) (point, []int, bool) {116+ best := point{}117+ bestCandidates := []int(nil)118+ bestCount := math.MaxInt119+120+ for y := range geo.height {121+ for x := range geo.width {122+ if state[y][x] != 0 {123+ continue124+ }125+ cell := point{x: x, y: y}126+ candidates := candidatesForCell(geo, state, cell)127+ if len(candidates) < bestCount {128+ best = cell129+ bestCandidates = candidates130+ bestCount = len(candidates)131+ if bestCount <= 1 {132+ return best, bestCandidates, true133+ }134+ }135+ }136+ }137+138+ if bestCount == math.MaxInt {139+ return point{}, nil, false140+ }141+ return best, bestCandidates, true142+}143+144+func candidatesForCell(geo *geometry, state grid, cell point) []int {145+ cageIdx := geo.cageGrid[cell.y][cell.x]146+ size := geo.cageSizes[cageIdx]147+ candidates := make([]int, 0, size)148+ for value := 1; value <= size; value++ {149+ if valueAllowed(geo, state, cell, value) {150+ candidates = append(candidates, value)151+ }152+ }153+ return candidates154+}155+156+func valueAllowed(geo *geometry, state grid, cell point, value int) bool {157+ if value <= 0 {158+ return false159+ }160+161+ cageIdx := geo.cageGrid[cell.y][cell.x]162+ if value > geo.cageSizes[cageIdx] {163+ return false164+ }165+166+ for _, other := range geo.cageCells[cageIdx] {167+ if other == cell {168+ continue169+ }170+ if state[other.y][other.x] == value {171+ return false172+ }173+ }174+175+ for step := 1; step < value; step++ {176+ if cell.x-step >= 0 && state[cell.y][cell.x-step] == value {177+ return false178+ }179+ if cell.x+step < geo.width && state[cell.y][cell.x+step] == value {180+ return false181+ }182+ if cell.y-step >= 0 && state[cell.y-step][cell.x] == value {183+ return false184+ }185+ if cell.y+step < geo.height && state[cell.y+step][cell.x] == value {186+ return false187+ }188+ }189+190+ return true191+}@@ -0,0 +1,389 @@
1+package rippleeffect2+3+import (4+ "image/color"5+ "strconv"6+ "strings"7+8+ "charm.land/lipgloss/v2"9+ "github.com/FelineStateMachine/puzzletea/game"10+ "github.com/FelineStateMachine/puzzletea/theme"11+)12+13+const cellWidth = 314+15+type visualKind int16+17+const (18+ visualNormal visualKind = iota19+ visualCrosshair20+ visualCage21+ visualCompleted22+ visualConflictCursor23+ visualCursor24+ visualSolved25+ visualConflict26+)27+28+func chooseVisualKind(cursor, solved, conflict, completed, cageHighlight, crosshair bool) visualKind {29+ switch {30+ case cursor && conflict:31+ return visualConflictCursor32+ case conflict:33+ return visualConflict34+ case solved:35+ return visualSolved36+ case cursor:37+ return visualCursor38+ case completed:39+ return visualCompleted40+ case cageHighlight:41+ return visualCage42+ case crosshair:43+ return visualCrosshair44+ default:45+ return visualNormal46+ }47+}48+49+func gridView(m Model) string {50+ rows := make([]string, 0, m.height*2+1)51+ activeCage := m.geo.cageGrid[m.cursor.Y][m.cursor.X]52+ completed := completedCageBackgrounds(m)53+ bridgeBG := bridgeBackgrounds(m, completed)54+ rows = append(rows, boundaryRow(m, 0, bridgeBG))55+ for y := range m.height {56+ rows = append(rows, contentRow(m, y, activeCage, bridgeBG))57+ rows = append(rows, boundaryRow(m, y+1, bridgeBG))58+ }59+ return lipgloss.JoinVertical(lipgloss.Left, rows...)60+}61+62+func contentRow(m Model, y, activeCage int, completed map[int]color.Color) string {63+ var b strings.Builder64+ for x := range m.width {65+ b.WriteString(renderBorderChar(m, verticalEdge(m.geo, x, y), m.solved, verticalGapBackground(m.geo, completed, x, y)))66+ b.WriteString(cellView(m, x, y, activeCage, completed[m.geo.cageGrid[y][x]]))67+ }68+ b.WriteString(renderBorderChar(m, verticalEdge(m.geo, m.width, y), m.solved, verticalGapBackground(m.geo, completed, m.width, y)))69+ return b.String()70+}71+72+func boundaryRow(m Model, y int, completed map[int]color.Color) string {73+ var b strings.Builder74+ for x := 0; x <= m.width; x++ {75+ b.WriteString(renderBorderChar(m, junctionRune(m.geo, x, y), m.solved, junctionGapBackground(m.geo, completed, x, y)))76+ if x == m.width {77+ continue78+ }79+80+ ch := ' '81+ if horizontalEdge(m.geo, x, y) {82+ ch = '─'83+ }84+ b.WriteString(renderBorderSegment(m, ch, m.solved, horizontalGapBackground(m.geo, completed, x, y)))85+ }86+ return b.String()87+}88+89+func cellView(m Model, x, y, activeCage int, completedBG color.Color) string {90+ p := theme.Current()91+ kind := chooseVisualKind(92+ x == m.cursor.X && y == m.cursor.Y,93+ m.solved,94+ m.conflicts[y][x],95+ completedBG != nil,96+ m.geo.cageGrid[y][x] == activeCage,97+ y == m.cursor.Y || x == m.cursor.X,98+ )99+100+ style := lipgloss.NewStyle().101+ Width(cellWidth).102+ AlignHorizontal(lipgloss.Center)103+104+ fg := p.FG105+ bg := p.BG106+ text := " · "107+ if value := m.grid[y][x]; value != 0 {108+ text = " " + strconv.Itoa(value) + " "109+ }110+111+ switch kind {112+ case visualConflictCursor:113+ fg = game.CursorFG()114+ bg = game.ConflictBG()115+ text = cursorText(m.grid[y][x])116+ case visualConflict:117+ fg = game.ConflictFG()118+ bg = game.ConflictBG()119+ case visualSolved:120+ fg = p.SolvedFG121+ bg = p.SuccessBG122+ case visualCursor:123+ fg = p.AccentText124+ bg = p.AccentBG125+ text = cursorText(m.grid[y][x])126+ case visualCompleted:127+ bg = completedBG128+ fg = theme.TextOnBG(bg)129+ case visualCage:130+ bg = p.SelectionBG131+ fg = theme.TextOnBG(bg)132+ case visualCrosshair:133+ bg = p.Surface134+ }135+136+ if m.givens[y][x] != 0 {137+ style = style.Bold(true)138+ }139+140+ return style.Foreground(fg).Background(bg).Render(text)141+}142+143+func cursorText(value int) string {144+ if value == 0 {145+ return game.CursorLeft + "·" + game.CursorRight146+ }147+ return game.CursorLeft + strconv.Itoa(value) + game.CursorRight148+}149+150+func completedCageBackgrounds(m Model) map[int]color.Color {151+ palette := theme.Current()152+ colors := palette.ThemeColors()153+ if len(colors) == 0 {154+ return nil155+ }156+157+ backgrounds := make(map[int]color.Color)158+ for cageIdx, cells := range m.geo.cageCells {159+ if !cageCompleted(m, cageIdx, cells) {160+ continue161+ }162+ backgrounds[cageIdx] = completedCageColor(m.geo.cages[cageIdx], colors, palette.Surface)163+ }164+ return backgrounds165+}166+167+func solvedBridgeBackgrounds(m Model) map[int]color.Color {168+ p := theme.Current()169+ backgrounds := make(map[int]color.Color, len(m.geo.cages))170+ for cageIdx := range m.geo.cages {171+ backgrounds[cageIdx] = p.SuccessBG172+ }173+ return backgrounds174+}175+176+func conflictBridgeBackgrounds(m Model) map[int]color.Color {177+ backgrounds := make(map[int]color.Color)178+ for cageIdx, cells := range m.geo.cageCells {179+ for _, cell := range cells {180+ if !m.conflicts[cell.y][cell.x] {181+ continue182+ }183+ backgrounds[cageIdx] = game.ConflictBG()184+ break185+ }186+ }187+ return backgrounds188+}189+190+func bridgeBackgrounds(m Model, completed map[int]color.Color) map[int]color.Color {191+ if m.solved {192+ return solvedBridgeBackgrounds(m)193+ }194+195+ backgrounds := activeCageBridgeBackgrounds(m)196+ for cageIdx, bg := range completed {197+ backgrounds[cageIdx] = bg198+ }199+ for cageIdx, bg := range conflictBridgeBackgrounds(m) {200+ backgrounds[cageIdx] = bg201+ }202+ return backgrounds203+}204+205+func activeCageBridgeBackgrounds(m Model) map[int]color.Color {206+ backgrounds := make(map[int]color.Color, 1)207+ if m.geo == nil || m.width == 0 || m.height == 0 {208+ return backgrounds209+ }210+211+ activeCage := m.geo.cageGrid[m.cursor.Y][m.cursor.X]212+ backgrounds[activeCage] = theme.Current().SelectionBG213+ return backgrounds214+}215+216+func cageCompleted(m Model, cageIdx int, cells []point) bool {217+ size := m.geo.cageSizes[cageIdx]218+ seen := make(map[int]struct{}, size)219+ for _, cell := range cells {220+ if m.conflicts[cell.y][cell.x] {221+ return false222+ }223+ value := m.grid[cell.y][cell.x]224+ if value < 1 || value > size {225+ return false226+ }227+ if _, exists := seen[value]; exists {228+ return false229+ }230+ seen[value] = struct{}{}231+ }232+ return len(seen) == size233+}234+235+func completedCageColor(cage Cage, colors []color.Color, base color.Color) color.Color {236+ if len(colors) == 0 {237+ return nil238+ }239+ first := cage.Cells[0]240+ index := (first.Y*37 + first.X*17 + cage.Size*13 + cage.ID*7) % len(colors)241+ return theme.Blend(base, colors[index], 0.52)242+}243+244+func renderBorderChar(m Model, ch rune, solved bool, bg color.Color) string {245+ p := theme.Current()246+ fg := p.Border247+ if solved {248+ fg = p.SuccessBorder249+ }250+ if bg == nil {251+ bg = p.BG252+ if solved {253+ bg = p.SuccessBG254+ }255+ }256+ return lipgloss.NewStyle().257+ Foreground(fg).258+ Background(bg).259+ Render(string(ch))260+}261+262+func renderBorderSegment(m Model, ch rune, solved bool, bg color.Color) string {263+ return strings.Repeat(renderBorderChar(m, ch, solved, bg), cellWidth)264+}265+266+func horizontalEdge(geo *geometry, x, y int) bool {267+ switch {268+ case y <= 0:269+ return geo.boundaries[0][x].has(boundaryTop)270+ case y >= geo.height:271+ return geo.boundaries[geo.height-1][x].has(boundaryBottom)272+ default:273+ return geo.boundaries[y][x].has(boundaryTop)274+ }275+}276+277+func verticalEdge(geo *geometry, x, y int) rune {278+ if hasVerticalEdge(geo, x, y) {279+ return '│'280+ }281+ return ' '282+}283+284+func hasVerticalEdge(geo *geometry, x, y int) bool {285+ switch {286+ case x <= 0:287+ return geo.boundaries[y][0].has(boundaryLeft)288+ case x >= geo.width:289+ return geo.boundaries[y][geo.width-1].has(boundaryRight)290+ default:291+ return geo.boundaries[y][x].has(boundaryLeft)292+ }293+}294+295+func junctionRune(geo *geometry, x, y int) rune {296+ north := y > 0 && hasVerticalEdge(geo, x, y-1)297+ south := y < geo.height && hasVerticalEdge(geo, x, y)298+ west := x > 0 && horizontalEdge(geo, x-1, y)299+ east := x < geo.width && horizontalEdge(geo, x, y)300+301+ switch {302+ case north && south && west && east:303+ return '┼'304+ case north && south && west:305+ return '┤'306+ case north && south && east:307+ return '├'308+ case west && east && north:309+ return '┴'310+ case west && east && south:311+ return '┬'312+ case south && east:313+ return '┌'314+ case south && west:315+ return '┐'316+ case north && east:317+ return '└'318+ case north && west:319+ return '┘'320+ case north || south:321+ return '│'322+ case west || east:323+ return '─'324+ default:325+ return ' '326+ }327+}328+329+func verticalGapBackground(geo *geometry, completed map[int]color.Color, x, y int) color.Color {330+ if hasVerticalEdge(geo, x, y) || x <= 0 || x >= geo.width {331+ return nil332+ }333+ left := geo.cageGrid[y][x-1]334+ right := geo.cageGrid[y][x]335+ if left != right {336+ return nil337+ }338+ return completed[left]339+}340+341+func horizontalGapBackground(geo *geometry, completed map[int]color.Color, x, y int) color.Color {342+ if horizontalEdge(geo, x, y) || y <= 0 || y >= geo.height {343+ return nil344+ }345+ top := geo.cageGrid[y-1][x]346+ bottom := geo.cageGrid[y][x]347+ if top != bottom {348+ return nil349+ }350+ return completed[top]351+}352+353+func junctionGapBackground(geo *geometry, completed map[int]color.Color, x, y int) color.Color {354+ if junctionRune(geo, x, y) != ' ' {355+ return nil356+ }357+358+ cages := make([]int, 0, 4)359+ if x > 0 && y > 0 {360+ cages = append(cages, geo.cageGrid[y-1][x-1])361+ }362+ if x < geo.width && y > 0 {363+ cages = append(cages, geo.cageGrid[y-1][x])364+ }365+ if x > 0 && y < geo.height {366+ cages = append(cages, geo.cageGrid[y][x-1])367+ }368+ if x < geo.width && y < geo.height {369+ cages = append(cages, geo.cageGrid[y][x])370+ }371+ if len(cages) == 0 {372+ return nil373+ }374+375+ cageID := cages[0]376+ for _, id := range cages[1:] {377+ if id != cageID {378+ return nil379+ }380+ }381+ return completed[cageID]382+}383+384+func statusBarView(showFullHelp bool) string {385+ if showFullHelp {386+ return game.StatusBarStyle().Render("1-9: place bkspc: clear arrows/wasd: move ctrl+n: menu ctrl+r: reset ctrl+h: help")387+ }388+ return game.StatusBarStyle().Render("1-9: place bkspc: clear")389+}@@ -44,7 +44,7 @@ func ImportModel(data []byte) (*Model, error) {
4444 Clues: s.Clues,4545 Rectangles: s.Rectangles,4646 }47- p.autoPlaceSingles()47+ p.autoPlaceForcedRectangles()4848 4949 return &Model{5050 puzzle: p,@@ -59,13 +59,14 @@ type Model struct {
5959 6060 // New creates a new Shikaku game model.6161 func New(mode ShikakuMode, puzzle Puzzle) game.Gamer {62- puzzle.autoPlaceSingles()63- return Model{62+ m := Model{6463 puzzle: puzzle,6564 cursor: game.Cursor{X: 0, Y: 0},6665 keys: DefaultKeyMap,6766 modeTitle: mode.Title(),6867 }68+ m.finalizePlacements()69+ return m6970 }7071 7172 func (m Model) Init() tea.Cmd { return nil }@@ -132,8 +133,7 @@ func (m Model) handleMouseClick(msg tea.MouseClickMsg) Model {
132133 // Right-click deletes rectangle at cursor.133134 owner := m.puzzle.CellOwner(col, row)134135 if owner >= 0 {135- m.puzzle.RemoveRectangle(owner)136- m.originValid = false136+ m.deleteRectangle(owner)137137 }138138 }139139 return m@@ -144,7 +144,7 @@ func (m Model) handleMouseMotion(msg tea.MouseMotionMsg) Model {
144144 return m145145 }146146 147- col, row, ok := m.screenToGrid(msg.X, msg.Y)147+ col, row, ok := m.screenToGridDrag(msg.X, msg.Y)148148 if !ok {149149 return m150150 }@@ -161,6 +161,12 @@ func (m Model) handleMouseRelease(msg tea.MouseReleaseMsg) Model {
161161 return m162162 }163163 164+ if col, row, ok := m.screenToGridDrag(msg.X, msg.Y); ok {165+ r := rectFromCorners(m.mouseDragAnchor[0], m.mouseDragAnchor[1], col, row)166+ m.mousePreview = &r167+ m.syncExpansionFromPreview()168+ }169+164170 preview := m.mousePreview165171 m.mouseDragAnchor = nil166172 m.mousePreview = nil@@ -187,11 +193,9 @@ func (m Model) handleMouseRelease(msg tea.MouseReleaseMsg) Model {
187193 H: preview.H,188194 }189195 190- // Place if valid: correct area and no overlap.191- if rect.Area() == clue.Value && !m.puzzle.Overlaps(rect, clue.ID) {192- m.puzzle.SetRectangle(rect)196+ if m.puzzle.ValidRectangleForClue(rect, clue.ID) {197+ m.commitRectangle(rect)193198 m.selectedClue = nil194- m.originValid = false195199 } else {196200 // Invalid: enter keyboard expansion mode on this clue so the197201 // player can fine-tune.@@ -275,7 +279,7 @@ func (m Model) handleNavMode(msg tea.KeyPressMsg) Model {
275279 // Delete rectangle at cursor position.276280 owner := m.puzzle.CellOwner(m.cursor.X, m.cursor.Y)277281 if owner >= 0 {278- m.puzzle.RemoveRectangle(owner)282+ m.deleteRectangle(owner)279283 }280284 case key.Matches(msg, m.keys.Cancel):281285 m = m.cancelPendingRectangle()@@ -329,14 +333,14 @@ func (m Model) handleExpansionMode(msg tea.KeyPressMsg) Model {
329333 }330334 case key.Matches(msg, m.keys.Select):331335 rect := m.expansion.rect(clue)332- if rect.Area() == clue.Value && !m.puzzle.Overlaps(rect, clue.ID) {333- m.puzzle.SetRectangle(rect)336+ if m.puzzle.ValidRectangleForClue(rect, clue.ID) {337+ m.commitRectangle(rect)334338 m.selectedClue = nil335339 }336340 case key.Matches(msg, m.keys.Cancel):337341 m = m.cancelPendingRectangle()338342 case key.Matches(msg, m.keys.Delete):339- m.puzzle.RemoveRectangle(clue.ID)343+ m.deleteRectangle(clue.ID)340344 m.selectedClue = nil341345 }342346 return m@@ -356,7 +360,7 @@ func (m Model) View() string {
356360 title,357361 grid,358362 game.StaticRow(info),359- game.StableRow(status, statusBarView(selected, false), statusBarView(selected, true)),363+ game.StableRow(status, statusBarVariants()...),360364 )361365 }362366 @@ -371,7 +375,7 @@ func (m Model) IsSolved() bool {
371375 372376 func (m Model) Reset() game.Gamer {373377 m.puzzle.Rectangles = nil374- m.puzzle.autoPlaceSingles()378+ m.finalizePlacements()375379 m.cursor = game.Cursor{}376380 m.selectedClue = nil377381 m.mouseDragAnchor = nil@@ -380,6 +384,21 @@ func (m Model) Reset() game.Gamer {
380384 return m381385 }382386 387+func (m *Model) finalizePlacements() {388+ m.puzzle.autoPlaceForcedRectangles()389+ m.originValid = false390+}391+392+func (m *Model) commitRectangle(rect Rectangle) {393+ m.puzzle.SetRectangle(rect)394+ m.finalizePlacements()395+}396+397+func (m *Model) deleteRectangle(clueID int) {398+ m.puzzle.RemoveRectangle(clueID)399+ m.finalizePlacements()400+}401+383402 func (m Model) GetDebugInfo() string {384403 status := "In Progress"385404 if m.puzzle.IsSolved() {@@ -10,6 +10,14 @@ import (
1010 // screenToGrid converts terminal screen coordinates to grid cell coordinates.1111 // Returns (col, row, ok) where ok is false if the click landed outside the grid.1212 func (m *Model) screenToGrid(screenX, screenY int) (col, row int, ok bool) {13+ return m.screenToGridAt(screenX, screenY, false)14+}15+16+func (m *Model) screenToGridDrag(screenX, screenY int) (col, row int, ok bool) {17+ return m.screenToGridAt(screenX, screenY, true)18+}19+20+func (m *Model) screenToGridAt(screenX, screenY int, includeSeparators bool) (col, row int, ok bool) {1321 ox, oy := m.cachedGridOrigin()1422 lx := screenX - ox1523 ly := screenY - oy@@ -17,9 +25,28 @@ func (m *Model) screenToGrid(screenX, screenY int) (col, row int, ok bool) {
1725 return 0, 0, false1826 }1927 20- // Shikaku has no separators: simple division by cell dimensions.21- col = lx / cellWidth22- row = ly // cellHeight is 128+ maxX := 029+ if m.puzzle.Width > 0 {30+ maxX = (m.puzzle.Width-1)*(cellWidth+1) + (cellWidth - 1)31+ }32+ maxY := 033+ if m.puzzle.Height > 0 {34+ maxY = (m.puzzle.Height - 1) * 235+ }36+ if lx > maxX || ly > maxY {37+ return 0, 0, false38+ }39+40+ if includeSeparators {41+ col = min((lx+cellWidth/2)/(cellWidth+1), m.puzzle.Width-1)42+ row = min((ly+1)/2, m.puzzle.Height-1)43+ } else {44+ col = lx / (cellWidth + 1)45+ row = ly / 246+ if lx%(cellWidth+1) >= cellWidth || ly%2 != 0 {47+ return 0, 0, false48+ }49+ }2350 2451 if col < 0 || col >= m.puzzle.Width || row < 0 || row >= m.puzzle.Height {2552 return 0, 0, false@@ -55,9 +82,7 @@ func (m *Model) cachedGridOrigin() (x, y int) {
5582 // Within the game view the layout is:5683 //5784 // title58-// ┌─────────────┐ <- border (1 line top)59-// │ cell grid │ <- border adds 1 char left60-// └─────────────┘85+// dynamic grid6186 // info6287 // status6388 func (m *Model) gridOrigin() (x, y int) {@@ -78,13 +103,14 @@ func (m *Model) gridOrigin() (x, y int) {
78103 title := game.TitleBarView("Shikaku", m.modeTitle, solved)79104 titleHeight := strings.Count(title, "\n") + 180105 81- // The grid is rendered inside a border. Measure its outer width to106+ // The grid is rendered directly inside the game view. Measure its width to82107 // compute horizontal padding from JoinVertical(Center, ...).83108 grid := gridView(*m, solved)84109 gridOuterWidth := lipgloss.Width(grid)85110 gridPadLeft := max((viewWidth-gridOuterWidth)/2, 0)86111 87- // Border adds 1 character on the left and 1 line on top.112+ // The dynamic grid includes an outer wall, so the first cell content starts113+ // one column right and one row below the grid's top-left corner.88114 const borderLeft = 189115 const borderTop = 190116 @@ -34,6 +34,8 @@ type Puzzle struct {
3434 clueIndex map[[2]int]int3535 }3636 37+const unownedRegionID = -138+3739 // FindClueAt returns the clue at position (x, y), or nil.3840 func (p *Puzzle) FindClueAt(x, y int) *Clue {3941 if p.clueIndex == nil {@@ -99,6 +101,154 @@ func rectsOverlap(a, b Rectangle) bool {
99101 return a.X < b.X+b.W && a.X+a.W > b.X && a.Y < b.Y+b.H && a.Y+a.H > b.Y100102 }101103 104+// ValidRectangleForClue reports whether rect is a legal placement for clueID.105+func (p *Puzzle) ValidRectangleForClue(rect Rectangle, clueID int) bool {106+ clue := p.FindClueByID(clueID)107+ if clue == nil {108+ return false109+ }110+ if rect.Area() != clue.Value {111+ return false112+ }113+ if rect.X < 0 || rect.Y < 0 || rect.X+rect.W > p.Width || rect.Y+rect.H > p.Height {114+ return false115+ }116+ if !rect.Contains(clue.X, clue.Y) {117+ return false118+ }119+ if p.Overlaps(rect, clueID) {120+ return false121+ }122+123+ clueCount := 0124+ for _, c := range p.Clues {125+ if rect.Contains(c.X, c.Y) {126+ clueCount++127+ if c.ID != clueID {128+ return false129+ }130+ }131+ }132+133+ return clueCount == 1134+}135+136+// CandidateRectanglesForClue returns every legal rectangle for the clue.137+func (p *Puzzle) CandidateRectanglesForClue(clueID int) []Rectangle {138+ clue := p.FindClueByID(clueID)139+ if clue == nil {140+ return nil141+ }142+143+ area := clue.Value144+ candidates := make([]Rectangle, 0, area)145+ for h := 1; h <= area; h++ {146+ if area%h != 0 {147+ continue148+ }149+ w := area / h150+ if w > p.Width || h > p.Height {151+ continue152+ }153+154+ minX := max(0, clue.X-w+1)155+ maxX := min(clue.X, p.Width-w)156+ minY := max(0, clue.Y-h+1)157+ maxY := min(clue.Y, p.Height-h)158+ for y := minY; y <= maxY; y++ {159+ for x := minX; x <= maxX; x++ {160+ rect := Rectangle{ClueID: clueID, X: x, Y: y, W: w, H: h}161+ if p.ValidRectangleForClue(rect, clueID) {162+ candidates = append(candidates, rect)163+ }164+ }165+ }166+ }167+168+ return candidates169+}170+171+func (p *Puzzle) encasedRectangleForClue(clueID int) (Rectangle, bool) {172+ clue := p.FindClueByID(clueID)173+ if clue == nil || p.FindRectangleForClue(clueID) != nil {174+ return Rectangle{}, false175+ }176+ if p.CellOwner(clue.X, clue.Y) != unownedRegionID {177+ return Rectangle{}, false178+ }179+180+ cells := p.unownedRegionFrom(clue.X, clue.Y)181+ if len(cells) == 0 {182+ return Rectangle{}, false183+ }184+185+ minX, maxX := cells[0][0], cells[0][0]186+ minY, maxY := cells[0][1], cells[0][1]187+ for _, cell := range cells[1:] {188+ minX = min(minX, cell[0])189+ maxX = max(maxX, cell[0])190+ minY = min(minY, cell[1])191+ maxY = max(maxY, cell[1])192+ }193+194+ rect := Rectangle{195+ ClueID: clueID,196+ X: minX,197+ Y: minY,198+ W: maxX - minX + 1,199+ H: maxY - minY + 1,200+ }201+ if rect.Area() != len(cells) {202+ return Rectangle{}, false203+ }204+ if !p.ValidRectangleForClue(rect, clueID) {205+ return Rectangle{}, false206+ }207+ return rect, true208+}209+210+func (p *Puzzle) unownedRegionFrom(x, y int) [][2]int {211+ if x < 0 || x >= p.Width || y < 0 || y >= p.Height || p.CellOwner(x, y) != unownedRegionID {212+ return nil213+ }214+215+ visited := make(map[[2]int]struct{}, p.Width*p.Height)216+ queue := [][2]int{{x, y}}217+ visited[[2]int{x, y}] = struct{}{}218+ cells := make([][2]int, 0, p.Width*p.Height)219+220+ for len(queue) > 0 {221+ cell := queue[0]222+ queue = queue[1:]223+ cells = append(cells, cell)224+225+ cx, cy := cell[0], cell[1]226+ neighbors := [][2]int{227+ {cx - 1, cy},228+ {cx + 1, cy},229+ {cx, cy - 1},230+ {cx, cy + 1},231+ }232+ for _, next := range neighbors {233+ nx, ny := next[0], next[1]234+ key := [2]int{nx, ny}235+ if nx < 0 || nx >= p.Width || ny < 0 || ny >= p.Height {236+ continue237+ }238+ if p.CellOwner(nx, ny) != unownedRegionID {239+ continue240+ }241+ if _, ok := visited[key]; ok {242+ continue243+ }244+ visited[key] = struct{}{}245+ queue = append(queue, key)246+ }247+ }248+249+ return cells250+}251+102252 // SetRectangle places or replaces the rectangle for a clue.103253 func (p *Puzzle) SetRectangle(rect Rectangle) {104254 for i, r := range p.Rectangles {@@ -131,19 +281,23 @@ func (p *Puzzle) CluesInRect(r Rectangle) []*Clue {
131281 return result132282 }133283 134-// autoPlaceSingles places 1x1 rectangles for all clues with Value == 1135-// that don't already have a rectangle. These clues have only one possible136-// placement, so requiring manual input is unnecessary busywork.137-func (p *Puzzle) autoPlaceSingles() {138- for _, c := range p.Clues {139- if c.Value == 1 && p.FindRectangleForClue(c.ID) == nil {140- p.SetRectangle(Rectangle{141- ClueID: c.ID,142- X: c.X,143- Y: c.Y,144- W: 1,145- H: 1,146- })284+// autoPlaceForcedRectangles places uniquely forced rectangles until stable.285+func (p *Puzzle) autoPlaceForcedRectangles() bool {286+ changedAny := false287+ for {288+ changed := false289+ for _, c := range p.Clues {290+ rect, ok := p.encasedRectangleForClue(c.ID)291+ if !ok {292+ continue293+ }294+295+ p.SetRectangle(rect)296+ changed = true297+ changedAny = true298+ }299+ if !changed {300+ return changedAny147301 }148302 }149303 }@@ -2,7 +2,9 @@ package shikaku
22 33 import (44 "encoding/json"5+ "image/color"56 "math/rand/v2"7+ "strings"68 "testing"79 810 tea "charm.land/bubbletea/v2"@@ -45,6 +47,17 @@ func tinyPuzzle() *Puzzle {
4547 }4648 }4749 50+func forcedCascadePuzzle() *Puzzle {51+ return &Puzzle{52+ Width: 3,53+ Height: 2,54+ Clues: []Clue{55+ {ID: 0, X: 0, Y: 0, Value: 4},56+ {ID: 1, X: 2, Y: 1, Value: 2},57+ },58+ }59+}60+4861 // --- FindClueAt ---4962 5063 func TestFindClueAt(t *testing.T) {@@ -213,6 +226,69 @@ func TestSetAndRemoveRectangle(t *testing.T) {
213226 })214227 }215228 229+func TestCandidateRectanglesForClue(t *testing.T) {230+ t.Run("single forced rectangle", func(t *testing.T) {231+ p := forcedCascadePuzzle()232+ got := p.CandidateRectanglesForClue(0)233+ if len(got) != 1 {234+ t.Fatalf("candidate count = %d, want 1", len(got))235+ }236+ want := Rectangle{ClueID: 0, X: 0, Y: 0, W: 2, H: 2}237+ if got[0] != want {238+ t.Fatalf("candidate = %+v, want %+v", got[0], want)239+ }240+ })241+242+ t.Run("multiple candidates remain", func(t *testing.T) {243+ p := tinyPuzzle()244+ if got := p.CandidateRectanglesForClue(0); len(got) != 2 {245+ t.Fatalf("candidate count = %d, want 2", len(got))246+ }247+ })248+}249+250+func TestAutoPlaceForcedRectangles(t *testing.T) {251+ t.Run("does not place from global uniqueness alone", func(t *testing.T) {252+ p := forcedCascadePuzzle()253+ p.autoPlaceForcedRectangles()254+ if len(p.Rectangles) != 0 {255+ t.Fatalf("rectangle count = %d, want 0", len(p.Rectangles))256+ }257+ })258+259+ t.Run("does not place when multiple candidates exist", func(t *testing.T) {260+ p := tinyPuzzle()261+ p.autoPlaceForcedRectangles()262+ if len(p.Rectangles) != 0 {263+ t.Fatalf("rectangle count = %d, want 0", len(p.Rectangles))264+ }265+ })266+267+ t.Run("places encased rectangle and cascades", func(t *testing.T) {268+ p := forcedCascadePuzzle()269+ p.SetRectangle(Rectangle{ClueID: 1, X: 2, Y: 0, W: 1, H: 2})270+ p.autoPlaceForcedRectangles()271+ if len(p.Rectangles) != 2 {272+ t.Fatalf("rectangle count = %d, want 2", len(p.Rectangles))273+ }274+ if r := p.FindRectangleForClue(0); r == nil || *r != (Rectangle{ClueID: 0, X: 0, Y: 0, W: 2, H: 2}) {275+ t.Fatalf("clue 0 rectangle = %+v, want 2x2 at origin", r)276+ }277+ if r := p.FindRectangleForClue(1); r == nil || *r != (Rectangle{ClueID: 1, X: 2, Y: 0, W: 1, H: 2}) {278+ t.Fatalf("clue 1 rectangle = %+v, want right column", r)279+ }280+ })281+282+ t.Run("does not overwrite existing rectangle", func(t *testing.T) {283+ p := tinyPuzzle()284+ p.SetRectangle(Rectangle{ClueID: 0, X: 0, Y: 0, W: 2, H: 1})285+ p.autoPlaceForcedRectangles()286+ if r := p.FindRectangleForClue(0); r == nil || *r != (Rectangle{ClueID: 0, X: 0, Y: 0, W: 2, H: 1}) {287+ t.Fatalf("existing rectangle changed to %+v", r)288+ }289+ })290+}291+216292 // --- IsSolved ---217293 218294 func TestIsSolved(t *testing.T) {@@ -383,6 +459,23 @@ func TestSaveLoadRoundTrip(t *testing.T) {
383459 })384460 }385461 462+func TestDeleteReplacesForcedRectangle(t *testing.T) {463+ m := New(NewMode("Test", "test", 3, 2, 4), *forcedCascadePuzzle()).(Model)464+ m.commitRectangle(Rectangle{ClueID: 1, X: 2, Y: 0, W: 1, H: 2})465+ if len(m.puzzle.Rectangles) != 2 {466+ t.Fatalf("rectangle count = %d, want 2", len(m.puzzle.Rectangles))467+ }468+469+ m.deleteRectangle(0)470+471+ if len(m.puzzle.Rectangles) != 2 {472+ t.Fatalf("rectangle count after delete = %d, want 2", len(m.puzzle.Rectangles))473+ }474+ if r := m.puzzle.FindRectangleForClue(0); r == nil || *r != (Rectangle{ClueID: 0, X: 0, Y: 0, W: 2, H: 2}) {475+ t.Fatalf("clue 0 rectangle = %+v, want forced replacement", r)476+ }477+}478+386479 func TestSaveJSON(t *testing.T) {387480 m := Model{388481 puzzle: Puzzle{@@ -528,14 +621,16 @@ func TestScreenToGrid(t *testing.T) {
528621 wantOk bool529622 }{530623 {"origin cell", ox, oy, 0, 0, true},531- {"cell (1,0)", ox + cellWidth, oy, 1, 0, true},532- {"cell (0,1)", ox, oy + 1, 0, 1, true},533- {"cell (2,3)", ox + 2*cellWidth, oy + 3, 2, 3, true},534- {"cell (4,4) last", ox + 4*cellWidth, oy + 4, 4, 4, true},624+ {"cell (1,0)", ox + cellWidth + 1, oy, 1, 0, true},625+ {"cell (0,1)", ox, oy + 2, 0, 1, true},626+ {"cell (2,3)", ox + 2*(cellWidth+1), oy + 3*2, 2, 3, true},627+ {"cell (4,4) last", ox + 4*(cellWidth+1), oy + 4*2, 4, 4, true},628+ {"vertical border miss", ox + cellWidth, oy, 0, 0, false},629+ {"horizontal border miss", ox, oy + 1, 0, 0, false},535630 {"outside left", ox - 1, oy, 0, 0, false},536631 {"outside top", ox, oy - 1, 0, 0, false},537- {"outside right", ox + 5*cellWidth, oy, 0, 0, false},538- {"outside bottom", ox, oy + 5, 0, 0, false},632+ {"outside right", ox + 5*(cellWidth+1), oy, 0, 0, false},633+ {"outside bottom", ox, oy + 10, 0, 0, false},539634 {"far outside", 0, 0, 0, 0, false},540635 }541636 @@ -553,6 +648,242 @@ func TestScreenToGrid(t *testing.T) {
553648 }554649 }555650 651+func TestMouseClickStartsDragOnContentCell(t *testing.T) {652+ m := Model{653+ puzzle: Puzzle{654+ Width: 5,655+ Height: 5,656+ Clues: []Clue{{ID: 0, X: 0, Y: 0, Value: 4}},657+ },658+ keys: DefaultKeyMap,659+ modeTitle: "Test",660+ termWidth: 120,661+ termHeight: 40,662+ }663+664+ x, y := cellScreenCoords(&m, 0, 0)665+ next, _ := m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})666+ got := next.(Model)667+668+ if !got.lastMouseHit {669+ t.Fatal("expected content-cell click to register as a hit")670+ }671+ if got.mouseDragAnchor == nil || *got.mouseDragAnchor != ([2]int{0, 0}) {672+ t.Fatalf("mouseDragAnchor = %#v, want {0,0}", got.mouseDragAnchor)673+ }674+ if got.mousePreview == nil || *got.mousePreview != (Rectangle{X: 0, Y: 0, W: 1, H: 1}) {675+ t.Fatalf("mousePreview = %#v, want 1x1 preview at origin", got.mousePreview)676+ }677+}678+679+func TestMouseClickOnBorderMisses(t *testing.T) {680+ m := Model{681+ puzzle: Puzzle{682+ Width: 5,683+ Height: 5,684+ Clues: []Clue{{ID: 0, X: 0, Y: 0, Value: 4}},685+ },686+ keys: DefaultKeyMap,687+ modeTitle: "Test",688+ termWidth: 120,689+ termHeight: 40,690+ }691+692+ x, y := cellScreenCoords(&m, 0, 0)693+ next, _ := m.Update(tea.MouseClickMsg{X: x + cellWidth, Y: y, Button: tea.MouseLeft})694+ got := next.(Model)695+696+ if got.lastMouseHit {697+ t.Fatal("expected border click to miss")698+ }699+ if got.mouseDragAnchor != nil {700+ t.Fatal("expected border click not to start drag")701+ }702+}703+704+func TestMouseDragReleasePlacesRectangle(t *testing.T) {705+ m := Model{706+ puzzle: *simplePuzzle(),707+ keys: DefaultKeyMap,708+ modeTitle: "Test",709+ termWidth: 120,710+ termHeight: 40,711+ originValid: false,712+ }713+714+ startX, startY := cellScreenCoords(&m, 0, 0)715+ endX, endY := cellScreenCoords(&m, 1, 1)716+717+ next, _ := m.Update(tea.MouseClickMsg{X: startX, Y: startY, Button: tea.MouseLeft})718+ dragging := next.(Model)719+ next, _ = dragging.Update(tea.MouseMotionMsg{X: endX, Y: endY, Button: tea.MouseLeft})720+ dragging = next.(Model)721+ next, _ = dragging.Update(tea.MouseReleaseMsg{X: endX, Y: endY, Button: tea.MouseLeft})722+ got := next.(Model)723+724+ if got.mouseDragAnchor != nil || got.mousePreview != nil {725+ t.Fatal("expected drag state to be cleared after release")726+ }727+ if r := got.puzzle.FindRectangleForClue(0); r == nil || *r != (Rectangle{ClueID: 0, X: 0, Y: 0, W: 2, H: 2}) {728+ t.Fatalf("rectangle = %+v, want 2x2 rectangle for clue 0", r)729+ }730+}731+732+func TestMouseDragAcrossSeparatorsPlacesRectangle(t *testing.T) {733+ m := Model{734+ puzzle: *simplePuzzle(),735+ keys: DefaultKeyMap,736+ modeTitle: "Test",737+ termWidth: 120,738+ termHeight: 40,739+ originValid: false,740+ }741+742+ startX, startY := cellScreenCoords(&m, 0, 0)743+ endX, endY := cellScreenCoords(&m, 1, 1)744+745+ next, _ := m.Update(tea.MouseClickMsg{X: startX, Y: startY, Button: tea.MouseLeft})746+ dragging := next.(Model)747+ next, _ = dragging.Update(tea.MouseMotionMsg{X: endX - 1, Y: endY - 1, Button: tea.MouseLeft})748+ dragging = next.(Model)749+ next, _ = dragging.Update(tea.MouseReleaseMsg{X: endX - 1, Y: endY - 1, Button: tea.MouseLeft})750+ got := next.(Model)751+752+ if got.selectedClue != nil {753+ t.Fatal("expected drag release over separators not to fall into expansion mode")754+ }755+ if r := got.puzzle.FindRectangleForClue(0); r == nil || *r != (Rectangle{ClueID: 0, X: 0, Y: 0, W: 2, H: 2}) {756+ t.Fatalf("rectangle = %+v, want 2x2 rectangle for clue 0", r)757+ }758+}759+760+func TestMouseRightClickDeletesRectangle(t *testing.T) {761+ m := Model{762+ puzzle: Puzzle{763+ Width: 2,764+ Height: 2,765+ Clues: []Clue{766+ {ID: 0, X: 0, Y: 0, Value: 2},767+ {ID: 1, X: 1, Y: 1, Value: 2},768+ },769+ Rectangles: []Rectangle{770+ {ClueID: 0, X: 0, Y: 0, W: 2, H: 1},771+ },772+ },773+ keys: DefaultKeyMap,774+ modeTitle: "Test",775+ termWidth: 120,776+ termHeight: 40,777+ }778+779+ x, y := cellScreenCoords(&m, 0, 0)780+ next, _ := m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseRight})781+ got := next.(Model)782+783+ if got.puzzle.FindRectangleForClue(0) != nil {784+ t.Fatal("expected right-click to delete rectangle")785+ }786+}787+788+func TestDynamicGridEdges(t *testing.T) {789+ m := Model{790+ puzzle: Puzzle{791+ Width: 4,792+ Height: 2,793+ Clues: []Clue{{ID: 0, X: 0, Y: 0, Value: 4}, {ID: 1, X: 2, Y: 0, Value: 4}},794+ Rectangles: []Rectangle{795+ {ClueID: 0, X: 0, Y: 0, W: 2, H: 2},796+ {ClueID: 1, X: 2, Y: 0, W: 2, H: 2},797+ },798+ },799+ }800+801+ if hasVerticalEdge(m, nil, 1, 0) {802+ t.Fatal("expected open interior edge inside committed rectangle")803+ }804+ if !hasVerticalEdge(m, nil, 2, 0) {805+ t.Fatal("expected wall between committed rectangles")806+ }807+ if horizontalEdge(m, nil, 0, 1) {808+ t.Fatal("expected open horizontal edge inside committed rectangle")809+ }810+}811+812+func TestPreviewOverridesCommittedWalls(t *testing.T) {813+ preview := &Rectangle{X: 0, Y: 0, W: 2, H: 1}814+ m := Model{815+ puzzle: Puzzle{816+ Width: 2,817+ Height: 1,818+ Clues: []Clue{{ID: 0, X: 0, Y: 0, Value: 1}, {ID: 1, X: 1, Y: 0, Value: 1}},819+ Rectangles: []Rectangle{820+ {ClueID: 0, X: 0, Y: 0, W: 1, H: 1},821+ {ClueID: 1, X: 1, Y: 0, W: 1, H: 1},822+ },823+ },824+ mousePreview: preview,825+ }826+827+ if !hasVerticalEdge(m, nil, 1, 0) {828+ t.Fatal("expected committed wall before preview overlay")829+ }830+ if hasVerticalEdge(m, preview, 1, 0) {831+ t.Fatal("expected preview to remove interior committed wall")832+ }833+}834+835+func TestDynamicGridGapBackgrounds(t *testing.T) {836+ m := Model{837+ puzzle: Puzzle{838+ Width: 2,839+ Height: 2,840+ Clues: []Clue{{ID: 0, X: 0, Y: 0, Value: 4}},841+ Rectangles: []Rectangle{842+ {ClueID: 0, X: 0, Y: 0, W: 2, H: 2},843+ },844+ },845+ }846+ bg := rectColors()[0]847+848+ if got := verticalGapBackground(m, nil, nil, false, 1, 0); !sameColor(got, bg) {849+ t.Fatal("expected committed rectangle to color vertical interior gap")850+ }851+ if got := horizontalGapBackground(m, nil, nil, false, 0, 1); !sameColor(got, bg) {852+ t.Fatal("expected committed rectangle to color horizontal interior gap")853+ }854+ if got := junctionGapBackground(m, nil, nil, false, 1, 1); !sameColor(got, bg) {855+ t.Fatal("expected committed rectangle to color interior junction")856+ }857+}858+859+func TestGridViewUsesUniformRows(t *testing.T) {860+ m := Model{861+ puzzle: Puzzle{862+ Width: 3,863+ Height: 2,864+ Clues: []Clue{{ID: 0, X: 0, Y: 0, Value: 4}, {ID: 1, X: 2, Y: 1, Value: 2}},865+ Rectangles: []Rectangle{866+ {ClueID: 0, X: 0, Y: 0, W: 2, H: 2},867+ {ClueID: 1, X: 2, Y: 0, W: 1, H: 2},868+ },869+ },870+ cursor: game.Cursor{X: 1, Y: 0},871+ }872+873+ view := gridView(m, false)874+ lines := strings.Split(view, "\n")875+ if got, want := len(lines), m.puzzle.Height*2+1; got != want {876+ t.Fatalf("grid line count = %d, want %d", got, want)877+ }878+879+ wantWidth := lipgloss.Width(lines[0])880+ for i, line := range lines {881+ if width := lipgloss.Width(line); width != wantWidth {882+ t.Fatalf("line %d width = %d, want %d", i, width, wantWidth)883+ }884+ }885+}886+556887 func TestHelpToggleInvalidatesOriginCache(t *testing.T) {557888 m := Model{558889 puzzle: *simplePuzzle(),@@ -590,6 +921,23 @@ func TestViewWidthStableWhenFullHelpExpanded(t *testing.T) {
590921 }591922 }592923 924+func TestViewHeightStableAcrossStatusModes(t *testing.T) {925+ selected := 0926+ views := []Model{927+ {puzzle: *simplePuzzle(), keys: DefaultKeyMap, modeTitle: "Test"},928+ {puzzle: *simplePuzzle(), keys: DefaultKeyMap, modeTitle: "Test", showFullHelp: true},929+ {puzzle: *simplePuzzle(), keys: DefaultKeyMap, modeTitle: "Test", selectedClue: &selected},930+ {puzzle: *simplePuzzle(), keys: DefaultKeyMap, modeTitle: "Test", selectedClue: &selected, showFullHelp: true},931+ }932+933+ wantHeight := lipgloss.Height(views[0].View())934+ for i, m := range views[1:] {935+ if got := lipgloss.Height(m.View()); got != wantHeight {936+ t.Fatalf("view %d height = %d, want %d", i+1, got, wantHeight)937+ }938+ }939+}940+593941 // --- rectFromCorners (P1) ---594942 595943 func TestRectFromCorners(t *testing.T) {@@ -726,3 +1074,17 @@ func TestModeSpawn(t *testing.T) {
7261074 }7271075 })7281076 }1077+1078+func sameColor(a, b color.Color) bool {1079+ if a == nil || b == nil {1080+ return a == b1081+ }1082+ ar, ag, ab, aa := a.RGBA()1083+ br, bg, bb, ba := b.RGBA()1084+ return ar == br && ag == bg && ab == bb && aa == ba1085+}1086+1087+func cellScreenCoords(m *Model, col, row int) (int, int) {1088+ ox, oy := m.gridOrigin()1089+ return ox + col*(cellWidth+1), oy + row*21090+}Not shown: this page already shows 6000 lines of diff.
Not shown: this page already shows 6000 lines of diff.
Binary file.
Not shown: this page already shows 6000 lines of diff.
Binary file.
Not shown: this page already shows 6000 lines of diff.