clone

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

add two new games and implment dynamic grid across older games

Dami committed f6b7327
commit
f6b7327e6785db421e1856dec4e55082e7f87cc6
parent
f068e922edd005712c70181e0e3ef085dafd0a41
author
Dami <dami.etoile@gmail.com>
59 files changed, +5858 -372
M.github/workflows/vhs.yml1+
MREADME.md24+-
Mcatalog/catalog.go26++
Mcatalog/catalog_test.go44+++
Mcmd/new.go4+-
Mcmd/new_export.go2+-
Afillomino/Export.go80+++++
Afillomino/Gamemode.go74+++++
Afillomino/Model.go162+++++++++++
Afillomino/PrintAdapter.go90++++++
Afillomino/README.md21+
Afillomino/fillomino_test.go394+++++++++++++++++++++++++++
Afillomino/generator.go214+++++++++++++++
Afillomino/grid.go151++++++++++
Afillomino/help.md27++
Afillomino/keys.go37+++
Afillomino/mouse.go82++++++
Afillomino/print_adapter_test.go10+
Afillomino/solver.go276+++++++++++++++++++
Afillomino/style.go372+++++++++++++++++++++++++
Mhashiwokakero/Model.go2+-
Mhashiwokakero/hashiwokakero_test.go19+
Mhashiwokakero/style.go12+-
Mhitori/Export.go19+-
Mhitori/Model.go59++--
Mhitori/hitori_test.go240++++++++++++++++-
Mhitori/solver.go92++++++
Mhitori/style.go94+++++--
Mjustfile2+
Mpdfexport/jsonl_test.go6+
Mpdfexport/printdata.go129+++++++++
Mpdfexport/printdata_test.go51+++
Mpdfexport/types.go23++
Mresolve/resolve.go40---
Mresolve/resolve_test.go139+---------
Arippleeffect/Export.go79+++++
Arippleeffect/Gamemode.go74+++++
Arippleeffect/Model.go163+++++++++++
Arippleeffect/PrintAdapter.go126+++++++++
Arippleeffect/README.md21+
Arippleeffect/generator.go278+++++++++++++++++++
Arippleeffect/geometry.go184+++++++++++++
Arippleeffect/grid.go95++++++
Arippleeffect/help.md27++
Arippleeffect/keys.go43+++
Arippleeffect/rippleeffect_test.go439++++++++++++++++++++++++++++++
Arippleeffect/solver.go191+++++++++++++
Arippleeffect/style.go389+++++++++++++++++++++++++++
Mshikaku/Export.go2+-
Mshikaku/Model.go49++-
Mshikaku/mouse.go42++-
Mshikaku/puzzle.go180+++++++++++-
Mshikaku/shikaku_test.go374+++++++++++++++++++++++++-
Mshikaku/style.go324+++++++++++++++++-----
Msudoku/style.go18+-
Avhs/fillomino.gifbin
Avhs/fillomino.tape63++++
Avhs/rippleeffect.gifbin
Avhs/rippleeffect.tape51+++
M .github/workflows/vhs.yml +1 -0
M README.md +21 -3
@@ -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 ![PuzzleTea menu](vhs/menu.gif)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+![Fillomino](vhs/fillomino.gif)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+![Ripple Effect](vhs/rippleeffect.gif)201+202+[Game details and controls](rippleeffect/README.md)203+186204 ### Nonogram187205 Fill cells to match row and column hints.188206
M catalog/catalog.go +26 -0
@@ -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+}
M catalog/catalog_test.go +44 -0
@@ -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 {
M cmd/new.go +2 -2
M cmd/new_export.go +1 -1
A fillomino/Export.go +80 -0
@@ -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+}
A fillomino/Gamemode.go +74 -0
@@ -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+}
A fillomino/Model.go +162 -0
@@ -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+}
A fillomino/PrintAdapter.go +90 -0
@@ -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+}
A fillomino/README.md +21 -0
A fillomino/fillomino_test.go +394 -0
@@ -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+}
A fillomino/generator.go +214 -0
@@ -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, &regions, 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+}
A fillomino/grid.go +151 -0
@@ -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+}
A fillomino/help.md +27 -0
A fillomino/keys.go +37 -0
A fillomino/mouse.go +82 -0
@@ -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+}
A fillomino/print_adapter_test.go +10 -0
A fillomino/solver.go +276 -0
@@ -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+}
A fillomino/style.go +372 -0
@@ -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+}
M hashiwokakero/Model.go +1 -1
M hashiwokakero/hashiwokakero_test.go +19 -0
M hashiwokakero/style.go +11 -1
M hitori/Export.go +9 -10
M hitori/Model.go +31 -28
@@ -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 }
M hitori/hitori_test.go +231 -9
@@ -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")
M hitori/solver.go +92 -0
@@ -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 {
M hitori/style.go +67 -27
@@ -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
M justfile +2 -0
M pdfexport/jsonl_test.go +6 -0
M pdfexport/printdata.go +129 -0
@@ -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 }
M pdfexport/printdata_test.go +51 -0
@@ -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+}
M pdfexport/types.go +23 -0
M resolve/resolve.go +0 -40
@@ -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-}
M resolve/resolve_test.go +4 -135
@@ -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 }
A rippleeffect/Export.go +79 -0
@@ -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+}
A rippleeffect/Gamemode.go +74 -0
@@ -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+}
A rippleeffect/Model.go +163 -0
@@ -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+}
A rippleeffect/PrintAdapter.go +126 -0
@@ -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+}
A rippleeffect/README.md +21 -0
A rippleeffect/generator.go +278 -0
@@ -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+}
A rippleeffect/geometry.go +184 -0
@@ -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+}
A rippleeffect/grid.go +95 -0
@@ -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+}
A rippleeffect/help.md +27 -0
A rippleeffect/keys.go +43 -0
A rippleeffect/rippleeffect_test.go +439 -0
@@ -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+}
A rippleeffect/solver.go +191 -0
@@ -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+}
A rippleeffect/style.go +389 -0
@@ -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+}
M shikaku/Export.go +1 -1
M shikaku/Model.go +34 -15
@@ -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() {
M shikaku/mouse.go +34 -8
@@ -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
M shikaku/puzzle.go +167 -13
@@ -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 }
M shikaku/shikaku_test.go +368 -6
@@ -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+}
M shikaku/style.go +256 -68

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

M sudoku/style.go +14 -4

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

A vhs/fillomino.gif +0 -0

Binary file.

A vhs/fillomino.tape +63 -0

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

A vhs/rippleeffect.gif +0 -0

Binary file.

A vhs/rippleeffect.tape +51 -0

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