clone

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

Merge pull request #46 from FelineStateMachine/feat/dynamic-grids-n-games

FelineStateMachine committed 878ef2b
Add dynamic grids plus Fillomino and Ripple Effect
commit
878ef2b3bba7d6760f67ed160a88dbdd933cf915
parent
f068e922edd005712c70181e0e3ef085dafd0a41
parent
fb793ef69726e5d279757061353472fd0243b4ca
author
FelineStateMachine <dami.etoile@gmail.com>
61 files changed, +6218 -424
M.github/workflows/vhs.yml1+
MREADME.md24+-
Mcatalog/catalog.go26++
Mcatalog/catalog_test.go44+++
Mcmd/new.go4+-
Mcmd/new_export.go2+-
Afillomino/Export.go90++++++
Afillomino/Gamemode.go74+++++
Afillomino/Model.go162+++++++++++
Afillomino/PrintAdapter.go90++++++
Afillomino/README.md21+
Afillomino/fillomino_test.go430+++++++++++++++++++++++++++++
Afillomino/generator.go202++++++++++++++
Afillomino/grid.go151++++++++++
Afillomino/help.md27++
Afillomino/keys.go37+++
Afillomino/mouse.go33++
Afillomino/print_adapter_test.go10+
Afillomino/solver.go255+++++++++++++++++
Afillomino/style.go400+++++++++++++++++++++++++++
Agame/dynamic_grid.go374++++++++++++++++++++++++++
Agame/dynamic_grid_test.go153++++++++++
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.go95+++++--
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.go264++++++++++++++++++
Mshikaku/Export.go1-
Mshikaku/Model.go49++-
Mshikaku/mouse.go72++---
Mshikaku/puzzle.go180+++++++++++-
Mshikaku/shikaku_test.go408+++++++++++++++++++++++++++-
Mshikaku/style.go278++++++++++++++-----
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 +90 -0
@@ -0,0 +1,90 @@
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+ MaxCellValue int `json:"max_cell_value,omitempty"`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+ Provided: serializeProvided(m.provided),25+ ModeTitle: m.modeTitle,26+ MaxCellValue: m.maxCellValue,27+ }28+ data, err := json.Marshal(save)29+ if err != nil {30+ return nil, fmt.Errorf("marshal fillomino 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 fillomino save: %w", err)39+ }40+ if save.Width <= 0 || save.Height <= 0 {41+ return nil, fmt.Errorf("invalid fillomino size %dx%d", save.Width, save.Height)42+ }43+44+ state, err := parseGrid(save.State, save.Width, save.Height)45+ if err != nil {46+ return nil, fmt.Errorf("decode fillomino state: %w", err)47+ }48+ provided := deserializeProvided(save.Provided, save.Width, save.Height)49+ for y := range save.Height {50+ for x := range save.Width {51+ if provided[y][x] && state[y][x] == 0 {52+ return nil, fmt.Errorf("provided cell at (%d,%d) is empty", x, y)53+ }54+ }55+ }56+57+ m := &Model{58+ width: save.Width,59+ height: save.Height,60+ grid: state,61+ initialGrid: initialGridForImport(state, provided),62+ provided: provided,63+ cursor: game.Cursor{},64+ keys: DefaultKeyMap,65+ modeTitle: save.ModeTitle,66+ maxCellValue: restoredMaxCellValue(save.MaxCellValue, state),67+ }68+ m.recompute()69+ return m, nil70+}71+72+func restoredMaxCellValue(savedMax int, state grid) int {73+ if savedMax > 0 {74+ return savedMax75+ }76+77+ return max(1, maxCellValue(state))78+}79+80+func initialGridForImport(state grid, provided [][]bool) grid {81+ initial := newGrid(len(state[0]), len(state))82+ for y := range len(state) {83+ for x := range len(state[y]) {84+ if provided[y][x] {85+ initial[y][x] = state[y][x]86+ }87+ }88+ }89+ return initial90+}
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 +430 -0
@@ -0,0 +1,430 @@
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+ "github.com/charmbracelet/x/ansi"14+)15+16+func TestValidateGridState(t *testing.T) {17+ tests := []struct {18+ name string19+ grid grid20+ solved bool21+ }{22+ {23+ name: "connected equal-number region is valid",24+ grid: grid{25+ {2, 2, 1},26+ {3, 3, 3},27+ },28+ solved: true,29+ },30+ {31+ name: "disconnected equal-number cells can coexist",32+ grid: grid{33+ {2, 2, 1},34+ {3, 0, 3},35+ },36+ solved: false,37+ },38+ {39+ name: "region size mismatch is invalid",40+ grid: grid{41+ {2, 2, 1},42+ {3, 3, 1},43+ },44+ solved: false,45+ },46+ {47+ name: "incomplete grid is not solved",48+ grid: grid{49+ {1, 0},50+ {0, 1},51+ },52+ solved: false,53+ },54+ }55+56+ for _, tt := range tests {57+ t.Run(tt.name, func(t *testing.T) {58+ got := validateGridState(tt.grid)59+ if got.solved != tt.solved {60+ t.Fatalf("solved = %v, want %v", got.solved, tt.solved)61+ }62+ })63+ }64+}65+66+func TestCountSolutions(t *testing.T) {67+ tests := []struct {68+ name string69+ givens grid70+ maxValue int71+ want int72+ }{73+ {74+ name: "unique solution puzzle",75+ givens: grid{76+ {2, 2, 1},77+ {3, 3, 3},78+ {2, 2, 1},79+ },80+ maxValue: 3,81+ want: 1,82+ },83+ {84+ name: "ambiguous puzzle",85+ givens: grid{86+ {0, 0, 0},87+ {0, 0, 0},88+ },89+ maxValue: 3,90+ want: 2,91+ },92+ {93+ name: "invalid fixed clues",94+ givens: grid{95+ {1, 1, 0},96+ {0, 0, 0},97+ },98+ maxValue: 3,99+ want: 0,100+ },101+ }102+103+ for _, tt := range tests {104+ t.Run(tt.name, func(t *testing.T) {105+ got := countSolutions(tt.givens, tt.maxValue, 2)106+ if got != tt.want {107+ t.Fatalf("countSolutions() = %d, want %d", got, tt.want)108+ }109+ })110+ }111+}112+113+func TestGeneratePuzzleSeeded(t *testing.T) {114+ mode := Modes[0].(Mode)115+ rngA := rand.New(rand.NewPCG(11, 22))116+ rngB := rand.New(rand.NewPCG(11, 22))117+118+ a, err := GeneratePuzzleSeeded(mode.Size, mode.Size, mode.MaxRegion, mode.GivenRatio, rngA)119+ if err != nil {120+ t.Fatal(err)121+ }122+ b, err := GeneratePuzzleSeeded(mode.Size, mode.Size, mode.MaxRegion, mode.GivenRatio, rngB)123+ if err != nil {124+ t.Fatal(err)125+ }126+127+ if encodeGrid(a.Givens) != encodeGrid(b.Givens) {128+ t.Fatalf("seeded givens differ:\n%s\n!=\n%s", encodeGrid(a.Givens), encodeGrid(b.Givens))129+ }130+ if validateGridState(a.Solution).solved != true {131+ t.Fatal("generated solution is not solved")132+ }133+ if got := countSolutions(a.Givens, mode.MaxRegion, 2); got != 1 {134+ t.Fatalf("generated puzzle solutions = %d, want 1", got)135+ }136+}137+138+func TestSaveRoundTrip(t *testing.T) {139+ puzzle := Puzzle{140+ Width: 3,141+ Height: 3,142+ Givens: grid{{2, 0, 1}, {0, 3, 0}, {2, 0, 1}},143+ }144+ g, err := New(NewMode("Test", "desc", 3, 3, 1), puzzle)145+ if err != nil {146+ t.Fatal(err)147+ }148+ model := g.(Model)149+ model.cursor = pointCursor(1, 0)150+ model.setCell(2)151+152+ save, err := model.GetSave()153+ if err != nil {154+ t.Fatal(err)155+ }156+ loaded, err := ImportModel(save)157+ if err != nil {158+ t.Fatal(err)159+ }160+161+ if got, want := loaded.grid[0][1], 2; got != want {162+ t.Fatalf("loaded grid[0][1] = %d, want %d", got, want)163+ }164+ if !loaded.provided[0][0] || !loaded.provided[0][2] || !loaded.provided[1][1] || !loaded.provided[2][2] {165+ t.Fatal("expected provided mask to persist")166+ }167+ if got, want := loaded.maxCellValue, 3; got != want {168+ t.Fatalf("loaded maxCellValue = %d, want %d", got, want)169+ }170+}171+172+func TestImportLegacySaveRestoresDigitCapFromState(t *testing.T) {173+ loaded, err := ImportModel([]byte(`{"width":3,"height":3,"state":"2 . 1\n. 3 .\n2 . 1","provided":"#.#\n.#.\n#.#","mode_title":"Test"}`))174+ if err != nil {175+ t.Fatal(err)176+ }177+178+ if got, want := loaded.maxCellValue, 3; got != want {179+ t.Fatalf("loaded maxCellValue = %d, want %d", got, want)180+ }181+}182+183+func TestCellViewConflictedCursorIsDistinct(t *testing.T) {184+ conflict := cellView(3, false, false, false, false, false, false, true, nil)185+ cursorConflict := cellView(3, false, true, false, false, false, false, true, nil)186+187+ if conflict == cursorConflict {188+ t.Fatal("conflicted cursor should render differently from plain conflicted cell")189+ }190+ if !strings.Contains(cursorConflict, game.CursorLeft+"3"+game.CursorRight) {191+ t.Fatal("conflicted cursor should include cursor markers")192+ }193+}194+195+func TestDynamicGridEdges(t *testing.T) {196+ g := grid{197+ {2, 2, 1},198+ {3, 3, 1},199+ }200+ h := grid{201+ {4, 1},202+ {4, 2},203+ }204+205+ if hasVerticalEdge(g, 1, 0) {206+ t.Fatal("expected open interior edge inside a region")207+ }208+ if !hasVerticalEdge(g, 2, 0) {209+ t.Fatal("expected border between different regions")210+ }211+ if horizontalEdge(h, 0, 1) {212+ t.Fatal("expected open horizontal edge inside a region")213+ }214+ if !horizontalEdge(g, 0, 0) {215+ t.Fatal("expected top outer border")216+ }217+}218+219+func TestCompletedRegionGapBackgrounds(t *testing.T) {220+ conflicts := validateGridState(grid{{4, 4}, {4, 4}}).conflicts221+ m := Model{222+ width: 2,223+ height: 2,224+ grid: grid{{4, 4}, {4, 4}},225+ conflicts: conflicts,226+ }227+ completed := completedRegionBackgrounds(m.grid, m.conflicts)228+ regionSet := map[point]struct{}{}229+230+ if got := verticalGapBackground(m, regionSet, completed, 1, 0); got == nil {231+ t.Fatal("expected completed region to color vertical interior gap")232+ }233+ if got := horizontalGapBackground(m, regionSet, completed, 0, 1); got == nil {234+ t.Fatal("expected completed region to color horizontal interior gap")235+ }236+ if got := junctionGapBackground(m, regionSet, completed, 1, 1); got == nil {237+ t.Fatal("expected completed region to color interior junction")238+ }239+}240+241+func TestConflictGapBackgroundUsesConflictColor(t *testing.T) {242+ conflicts := validateGridState(grid{{4, 4}, {4, 4}}).conflicts243+ m := Model{244+ width: 2,245+ height: 2,246+ grid: grid{{4, 4}, {4, 4}},247+ conflicts: conflicts,248+ }249+ m.conflicts[0][0] = true250+251+ if got := verticalGapBackground(m, nil, nil, 1, 0); !sameColor(got, game.ConflictBG()) {252+ t.Fatal("expected conflict color to bridge vertical interior gap")253+ }254+ if got := junctionGapBackground(m, nil, nil, 1, 1); !sameColor(got, game.ConflictBG()) {255+ t.Fatal("expected conflict color to bridge interior junction")256+ }257+}258+259+func TestGridViewUsesUniformRows(t *testing.T) {260+ conflicts := validateGridState(grid{{2, 2, 1}, {3, 3, 3}, {1, 2, 2}}).conflicts261+ m := Model{262+ width: 3,263+ height: 3,264+ grid: grid{{2, 2, 1}, {3, 3, 3}, {1, 2, 2}},265+ provided: newProvidedMask(grid{{2, 2, 1}, {3, 3, 3}, {1, 2, 2}}),266+ conflicts: conflicts,267+ cursor: pointCursor(1, 1),268+ }269+270+ view := gridView(m)271+ lines := strings.Split(view, "\n")272+ if got, want := len(lines), m.height*2+1; got != want {273+ t.Fatalf("grid line count = %d, want %d", got, want)274+ }275+276+ wantWidth := lipgloss.Width(lines[0])277+ for i, line := range lines {278+ if width := lipgloss.Width(line); width != wantWidth {279+ t.Fatalf("line %d width = %d, want %d", i, width, wantWidth)280+ }281+ }282+}283+284+func TestGridViewOmitsBorderBetweenAdjacentEmptyCells(t *testing.T) {285+ m := Model{286+ width: 2,287+ height: 1,288+ grid: grid{{0, 0}},289+ provided: newProvidedMask(grid{{0, 0}}),290+ conflicts: validateGridState(grid{{0, 0}}).conflicts,291+ }292+293+ view := ansi.Strip(gridView(m))294+ lines := strings.Split(view, "\n")295+ if len(lines) < 2 {296+ t.Fatalf("expected at least 2 lines, got %d", len(lines))297+ }298+299+ content := []rune(lines[1])300+ if got := content[4]; got != ' ' {301+ t.Fatalf("separator between adjacent empty cells = %q, want space", got)302+ }303+}304+305+func TestSolvedGapBackgroundUsesSolvedColor(t *testing.T) {306+ conflicts := validateGridState(grid{{2, 2}}).conflicts307+ m := Model{308+ width: 2,309+ height: 1,310+ grid: grid{{2, 2}},311+ conflicts: conflicts,312+ solved: true,313+ }314+ got := verticalGapBackground(m, nil, nil, 1, 0)315+ if got == nil {316+ t.Fatal("expected solved gap background")317+ }318+ if !sameColor(got, theme.Current().SuccessBG) {319+ t.Fatal("expected solved gap background to use solved color")320+ }321+}322+323+func TestScreenToGrid(t *testing.T) {324+ m := Model{325+ width: 3,326+ height: 3,327+ grid: grid{{2, 2, 1}, {3, 3, 3}, {1, 2, 2}},328+ provided: newProvidedMask(grid{{2, 2, 1}, {3, 3, 3}, {1, 2, 2}}),329+ conflicts: validateGridState(grid{{2, 2, 1}, {3, 3, 3}, {1, 2, 2}}).conflicts,330+ keys: DefaultKeyMap,331+ modeTitle: "Test",332+ termWidth: 120,333+ termHeight: 40,334+ }335+336+ ox, oy := m.gridOrigin()337+ tests := []struct {338+ name string339+ screenX int340+ screenY int341+ wantCol int342+ wantRow int343+ wantOK bool344+ }{345+ {"origin cell", ox, oy, 0, 0, true},346+ {"cell (1,0)", ox + cellWidth + 1, oy, 1, 0, true},347+ {"cell (0,1)", ox, oy + 2, 0, 1, true},348+ {"vertical border miss", ox + cellWidth, oy, 0, 0, false},349+ {"horizontal border miss", ox, oy + 1, 0, 0, false},350+ {"outside left", ox - 1, oy, 0, 0, false},351+ {"outside bottom", ox, oy + 6, 0, 0, false},352+ }353+354+ for _, tt := range tests {355+ t.Run(tt.name, func(t *testing.T) {356+ col, row, ok := m.screenToGrid(tt.screenX, tt.screenY)357+ if ok != tt.wantOK {358+ t.Fatalf("ok = %v, want %v", ok, tt.wantOK)359+ }360+ if ok && (col != tt.wantCol || row != tt.wantRow) {361+ t.Fatalf("screenToGrid(%d, %d) = (%d, %d), want (%d, %d)", tt.screenX, tt.screenY, col, row, tt.wantCol, tt.wantRow)362+ }363+ })364+ }365+}366+367+func TestMouseClickMovesCursor(t *testing.T) {368+ puzzle := Puzzle{369+ Width: 3,370+ Height: 3,371+ Givens: grid{{2, 0, 1}, {0, 3, 0}, {2, 0, 1}},372+ }373+ g, err := New(NewMode("Test", "desc", 3, 3, 3), puzzle)374+ if err != nil {375+ t.Fatal(err)376+ }377+ m := g.(Model)378+ m.termWidth = 120379+ m.termHeight = 40380+381+ x, y := fillominoCellScreenCoords(&m, 2, 1)382+ next, _ := m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})383+ got := next.(Model)384+385+ if got.cursor.X != 2 || got.cursor.Y != 1 {386+ t.Fatalf("cursor = (%d,%d), want (2,1)", got.cursor.X, got.cursor.Y)387+ }388+}389+390+func TestMouseClickOnBorderDoesNotMoveCursor(t *testing.T) {391+ puzzle := Puzzle{392+ Width: 3,393+ Height: 3,394+ Givens: grid{{2, 0, 1}, {0, 3, 0}, {2, 0, 1}},395+ }396+ g, err := New(NewMode("Test", "desc", 3, 3, 3), puzzle)397+ if err != nil {398+ t.Fatal(err)399+ }400+ m := g.(Model)401+ m.termWidth = 120402+ m.termHeight = 40403+ m.cursor = pointCursor(1, 1)404+405+ x, y := fillominoCellScreenCoords(&m, 1, 1)406+ next, _ := m.Update(tea.MouseClickMsg{X: x + cellWidth, Y: y, Button: tea.MouseLeft})407+ got := next.(Model)408+409+ if got.cursor.X != 1 || got.cursor.Y != 1 {410+ t.Fatalf("cursor moved to (%d,%d), want unchanged (1,1)", got.cursor.X, got.cursor.Y)411+ }412+}413+414+func pointCursor(x, y int) game.Cursor {415+ return game.Cursor{X: x, Y: y}416+}417+418+func sameColor(a, b color.Color) bool {419+ if a == nil || b == nil {420+ return a == b421+ }422+ ar, ag, ab, aa := a.RGBA()423+ br, bg, bb, ba := b.RGBA()424+ return ar == br && ag == bg && ab == bb && aa == ba425+}426+427+func fillominoCellScreenCoords(m *Model, col, row int) (int, int) {428+ ox, oy := m.gridOrigin()429+ return ox + col*(cellWidth+1), oy + row*2430+}
A fillomino/generator.go +202 -0
@@ -0,0 +1,202 @@
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 rectangleCandidates(used [][]bool, start point, maxRegion int, rng *rand.Rand) []region {100+ height := len(used)101+ width := len(used[0])102+103+ var candidates []region104+ for h := 1; start.y+h <= height; h++ {105+ for w := 1; start.x+w <= width; w++ {106+ size := w * h107+ if size > maxRegion {108+ break109+ }110+ cells := make([]point, 0, size)111+ valid := true112+ for y := start.y; y < start.y+h && valid; y++ {113+ for x := start.x; x < start.x+w; x++ {114+ if used[y][x] {115+ valid = false116+ break117+ }118+ cells = append(cells, point{x: x, y: y})119+ }120+ }121+ if !valid {122+ continue123+ }124+ candidates = append(candidates, region{cells: cells, size: size})125+ }126+ }127+ rng.Shuffle(len(candidates), func(i, j int) {128+ candidates[i], candidates[j] = candidates[j], candidates[i]129+ })130+ return candidates131+}132+133+func touchesEqualRegion(solution grid, shape []point, size int) bool {134+ shapeCells := make(map[point]struct{}, len(shape))135+ for _, cell := range shape {136+ shapeCells[cell] = struct{}{}137+ }138+139+ for _, cell := range shape {140+ for _, next := range orthogonalNeighbors(cell, len(solution[0]), len(solution)) {141+ if _, ok := shapeCells[next]; ok {142+ continue143+ }144+ if solution[next.y][next.x] == size {145+ return true146+ }147+ }148+ }149+150+ return false151+}152+153+func applyShape(solution grid, used [][]bool, shape []point, size int, place bool) {154+ value := 0155+ if place {156+ value = size157+ }158+ for _, cell := range shape {159+ used[cell.y][cell.x] = place160+ solution[cell.y][cell.x] = value161+ }162+}163+164+func buildStartingGivens(solution grid) grid {165+ return cloneGrid(solution)166+}167+168+func removeClues(givens grid, maxRegion int, givenRatio float64, rng *rand.Rand) grid {169+ width := len(givens[0])170+ height := len(givens)171+ target := int(float64(width*height) * givenRatio)172+ if target < 1 {173+ target = 1174+ }175+176+ cells := make([]point, 0, width*height)177+ for y := range height {178+ for x := range width {179+ if givens[y][x] != 0 {180+ cells = append(cells, point{x: x, y: y})181+ }182+ }183+ }184+ rng.Shuffle(len(cells), func(i, j int) {185+ cells[i], cells[j] = cells[j], cells[i]186+ })187+188+ remaining := len(cells)189+ for _, cell := range cells {190+ if remaining <= target {191+ break192+ }193+ value := givens[cell.y][cell.x]194+ givens[cell.y][cell.x] = 0195+ if countSolutions(givens, maxRegion, 2) != 1 {196+ givens[cell.y][cell.x] = value197+ continue198+ }199+ remaining--200+ }201+ return givens202+}
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 +33 -0
A fillomino/print_adapter_test.go +10 -0
A fillomino/solver.go +255 -0
@@ -0,0 +1,255 @@
1+package fillomino2+3+import (4+ "errors"5+ "math"6+)7+8+var errInvalidGridEncoding = errors.New("invalid fillomino grid encoding")9+10+type component struct {11+ value int12+ cells []point13+ frontier map[point]struct{}14+}15+16+type validationResult struct {17+ solved bool18+ conflicts [][]bool19+}20+21+func validateGridState(g grid) validationResult {22+ height := len(g)23+ width := 024+ if height > 0 {25+ width = len(g[0])26+ }27+28+ conflicts := make([][]bool, height)29+ for y := range height {30+ conflicts[y] = make([]bool, width)31+ }32+33+ visited := make([][]bool, height)34+ for y := range height {35+ visited[y] = make([]bool, width)36+ }37+38+ complete := true39+ for y := range height {40+ for x := range width {41+ if g[y][x] == 0 {42+ complete = false43+ continue44+ }45+ if visited[y][x] {46+ continue47+ }48+49+ comp := buildComponent(g, point{x: x, y: y}, visited)50+ markComponentConflicts(g, comp, complete, conflicts)51+ }52+ }53+54+ solved := complete && !hasConflicts(conflicts)55+ return validationResult{56+ solved: solved,57+ conflicts: conflicts,58+ }59+}60+61+func hasConflicts(conflicts [][]bool) bool {62+ for y := range len(conflicts) {63+ for x := range len(conflicts[y]) {64+ if conflicts[y][x] {65+ return true66+ }67+ }68+ }69+ return false70+}71+72+func buildComponent(g grid, start point, visited [][]bool) component {73+ value := g[start.y][start.x]74+ queue := []point{start}75+ visited[start.y][start.x] = true76+ comp := component{77+ value: value,78+ frontier: make(map[point]struct{}),79+ }80+81+ for len(queue) > 0 {82+ curr := queue[0]83+ queue = queue[1:]84+ comp.cells = append(comp.cells, curr)85+86+ for _, next := range orthogonalNeighbors(curr, len(g[0]), len(g)) {87+ switch {88+ case g[next.y][next.x] == value && !visited[next.y][next.x]:89+ visited[next.y][next.x] = true90+ queue = append(queue, next)91+ case g[next.y][next.x] == 0:92+ comp.frontier[next] = struct{}{}93+ }94+ }95+ }96+97+ return comp98+}99+100+func markComponentConflicts(g grid, comp component, assumeComplete bool, conflicts [][]bool) {101+ size := len(comp.cells)102+ value := comp.value103+ if value <= 0 {104+ for _, cell := range comp.cells {105+ conflicts[cell.y][cell.x] = true106+ }107+ return108+ }109+110+ if size > value || size+len(comp.frontier) < value {111+ for _, cell := range comp.cells {112+ conflicts[cell.y][cell.x] = true113+ }114+ for cell := range comp.frontier {115+ conflicts[cell.y][cell.x] = true116+ }117+ return118+ }119+120+ if assumeComplete && size != value {121+ for _, cell := range comp.cells {122+ conflicts[cell.y][cell.x] = true123+ }124+ }125+}126+127+func orthogonalNeighbors(p point, width, height int) []point {128+ neighbors := make([]point, 0, 4)129+ if p.x > 0 {130+ neighbors = append(neighbors, point{x: p.x - 1, y: p.y})131+ }132+ if p.x+1 < width {133+ neighbors = append(neighbors, point{x: p.x + 1, y: p.y})134+ }135+ if p.y > 0 {136+ neighbors = append(neighbors, point{x: p.x, y: p.y - 1})137+ }138+ if p.y+1 < height {139+ neighbors = append(neighbors, point{x: p.x, y: p.y + 1})140+ }141+ return neighbors142+}143+144+func countSolutions(givens grid, maxValue, limit int) int {145+ working := cloneGrid(givens)146+ return searchSolutions(working, maxValue, limit)147+}148+149+func searchSolutions(g grid, maxValue, limit int) int {150+ if limit <= 0 {151+ return 0152+ }153+ if !isPartialGridValid(g) {154+ return 0155+ }156+157+ cell, candidates, ok := chooseNextCell(g, maxValue)158+ if !ok {159+ if validateGridState(g).solved {160+ return 1161+ }162+ return 0163+ }164+165+ total := 0166+ for _, value := range candidates {167+ g[cell.y][cell.x] = value168+ total += searchSolutions(g, maxValue, limit-total)169+ if total >= limit {170+ g[cell.y][cell.x] = 0171+ return total172+ }173+ g[cell.y][cell.x] = 0174+ }175+ return total176+}177+178+func chooseNextCell(g grid, maxValue int) (point, []int, bool) {179+ best := point{}180+ bestCandidates := []int(nil)181+ bestCount := math.MaxInt182+183+ for y := range len(g) {184+ for x := range len(g[y]) {185+ if g[y][x] != 0 {186+ continue187+ }188+189+ candidates := candidatesForCell(g, point{x: x, y: y}, maxValue)190+ if len(candidates) == 0 {191+ return point{x: x, y: y}, nil, true192+ }193+ if len(candidates) < bestCount {194+ best = point{x: x, y: y}195+ bestCandidates = candidates196+ bestCount = len(candidates)197+ if bestCount == 1 {198+ return best, bestCandidates, true199+ }200+ }201+ }202+ }203+204+ if bestCount == math.MaxInt {205+ return point{}, nil, false206+ }207+ return best, bestCandidates, true208+}209+210+func candidatesForCell(g grid, cell point, maxValue int) []int {211+ candidates := make([]int, 0, maxValue)212+ for value := 1; value <= maxValue; value++ {213+ g[cell.y][cell.x] = value214+ if isPartialGridValid(g) {215+ candidates = append(candidates, value)216+ }217+ g[cell.y][cell.x] = 0218+ }219+ return candidates220+}221+222+func isPartialGridValid(g grid) bool {223+ height := len(g)224+ if height == 0 {225+ return true226+ }227+ width := len(g[0])228+229+ visited := make([][]bool, height)230+ for y := range height {231+ visited[y] = make([]bool, width)232+ }233+234+ for y := range height {235+ for x := range width {236+ if g[y][x] == 0 || visited[y][x] {237+ continue238+ }239+240+ comp := buildComponent(g, point{x: x, y: y}, visited)241+ size := len(comp.cells)242+ if size > comp.value {243+ return false244+ }245+ if size+len(comp.frontier) < comp.value {246+ return false247+ }248+ if len(comp.frontier) == 0 && size != comp.value {249+ return false250+ }251+ }252+ }253+254+ return true255+}
A fillomino/style.go +400 -0
@@ -0,0 +1,400 @@
1+package fillomino2+3+import (4+ "image/color"5+ "strconv"6+7+ "charm.land/lipgloss/v2"8+ "github.com/FelineStateMachine/puzzletea/game"9+ "github.com/FelineStateMachine/puzzletea/theme"10+)11+12+const cellWidth = game.DynamicGridCellWidth13+14+type renderGridState struct {15+ zones [][]int16+ activeZone int17+ completed map[int]color.Color18+}19+20+func cellBaseStyle() lipgloss.Style {21+ p := theme.Current()22+ return lipgloss.NewStyle().23+ Foreground(p.FG).24+ Background(p.BG)25+}26+27+func emptyCellStyle() lipgloss.Style {28+ p := theme.Current()29+ return lipgloss.NewStyle().30+ Foreground(p.TextDim).31+ Background(p.BG)32+}33+34+func cellView(35+ value int,36+ provided, cursor, rowHighlight, colHighlight, regionHighlight, solved, conflict bool,37+ completedBG color.Color,38+) string {39+ p := theme.Current()40+ style := cellBaseStyle()41+ text := " · "42+ if value == 0 {43+ style = emptyCellStyle()44+ } else {45+ text = lipgloss.NewStyle().Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(strconv.Itoa(value))46+ }47+48+ if provided && value != 0 {49+ style = style.Bold(true)50+ }51+ if conflict && cursor {52+ style = conflictedCursorStyle()53+ } else if conflict {54+ style = style.Foreground(game.ConflictFG()).Background(game.ConflictBG())55+ } else if solved {56+ style = style.Foreground(game.SolvedFG()).Background(p.SuccessBG)57+ } else if cursor {58+ style = game.CursorStyle()59+ } else if completedBG != nil {60+ style = style.Background(completedBG).Foreground(theme.TextOnBG(completedBG))61+ } else if regionHighlight {62+ style = style.Background(p.HighlightBG)63+ } else if rowHighlight || colHighlight {64+ style = style.Background(p.Surface)65+ }66+67+ if cursor {68+ if value == 0 {69+ text = game.CursorLeft + "·" + game.CursorRight70+ } else {71+ text = game.CursorLeft + strconv.Itoa(value) + game.CursorRight72+ }73+ }74+75+ return style.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(text)76+}77+78+func conflictedCursorStyle() lipgloss.Style {79+ return lipgloss.NewStyle().80+ Bold(true).81+ Foreground(game.CursorFG()).82+ Background(game.ConflictBG())83+}84+85+func gridView(m Model) string {86+ renderState := buildRenderGridState(m)87+88+ return game.RenderDynamicGrid(game.DynamicGridSpec{89+ Width: m.width,90+ Height: m.height,91+ Solved: m.solved,92+ Cell: func(x, y int) string {93+ zone := renderState.zones[y][x]94+ completedBG := renderState.completed[zone]95+ return cellView(96+ m.grid[y][x],97+ m.provided[y][x],98+ x == m.cursor.X && y == m.cursor.Y,99+ y == m.cursor.Y,100+ x == m.cursor.X,101+ renderState.activeZone >= 0 && zone == renderState.activeZone,102+ m.solved,103+ m.conflicts[y][x],104+ completedBG,105+ )106+ },107+ ZoneAt: func(x, y int) int {108+ return renderState.zones[y][x]109+ },110+ ZoneFill: func(zone int) color.Color {111+ p := theme.Current()112+ switch {113+ case m.solved:114+ return p.SuccessBG115+ case renderState.completed[zone] != nil:116+ return renderState.completed[zone]117+ case renderState.activeZone >= 0 && zone == renderState.activeZone:118+ return p.HighlightBG119+ default:120+ return nil121+ }122+ },123+ BridgeFill: func(bridge game.DynamicGridBridge) color.Color {124+ for i := 0; i < bridge.Count; i++ {125+ cell := bridge.Cells[i]126+ if m.conflicts[cell.Y][cell.X] {127+ return game.ConflictBG()128+ }129+ }130+ return nil131+ },132+ })133+}134+135+func buildRenderGridState(m Model) renderGridState {136+ height := len(m.grid)137+ if height == 0 {138+ return renderGridState{}139+ }140+ width := len(m.grid[0])141+ zones := make([][]int, height)142+ for y := range height {143+ zones[y] = make([]int, width)144+ }145+146+ visited := make([][]bool, height)147+ for y := range height {148+ visited[y] = make([]bool, width)149+ }150+151+ palette := theme.Current()152+ colors := palette.ThemeColors()153+ completed := make(map[int]color.Color)154+ nextZone := 0155+ activeZone := -1156+ emptyZone := nextZone157+ nextZone++158+159+ for y := range height {160+ for x := range width {161+ if m.grid[y][x] == 0 {162+ zones[y][x] = emptyZone163+ continue164+ }165+ if visited[y][x] {166+ continue167+ }168+169+ comp := buildComponent(m.grid, point{x: x, y: y}, visited)170+ zone := nextZone171+ nextZone++172+ for _, cell := range comp.cells {173+ zones[cell.y][cell.x] = zone174+ }175+ if len(colors) > 0 && len(comp.cells) == comp.value && !componentHasConflict(comp, m.conflicts) {176+ completed[zone] = completedRegionColor(comp, colors, palette.Surface)177+ }178+ }179+ }180+181+ if m.cursor.Y >= 0 && m.cursor.Y < height && m.cursor.X >= 0 && m.cursor.X < width && m.grid[m.cursor.Y][m.cursor.X] != 0 {182+ activeZone = zones[m.cursor.Y][m.cursor.X]183+ }184+185+ return renderGridState{186+ zones: zones,187+ activeZone: activeZone,188+ completed: completed,189+ }190+}191+192+func horizontalEdge(g grid, x, y int) bool {193+ switch {194+ case y <= 0, y >= len(g):195+ return true196+ default:197+ top := point{x: x, y: y - 1}198+ bottom := point{x: x, y: y}199+ return !sameRegion(g, top, bottom)200+ }201+}202+203+func hasVerticalEdge(g grid, x, y int) bool {204+ width := len(g[0])205+ switch {206+ case x <= 0, x >= width:207+ return true208+ default:209+ left := point{x: x - 1, y: y}210+ right := point{x: x, y: y}211+ return !sameRegion(g, left, right)212+ }213+}214+215+func junctionRune(g grid, x, y int) rune {216+ height := len(g)217+ width := len(g[0])218+ north := y > 0 && hasVerticalEdge(g, x, y-1)219+ south := y < height && hasVerticalEdge(g, x, y)220+ west := x > 0 && horizontalEdge(g, x-1, y)221+ east := x < width && horizontalEdge(g, x, y)222+223+ switch {224+ case north && south && west && east:225+ return '┼'226+ case north && south && west:227+ return '┤'228+ case north && south && east:229+ return '├'230+ case west && east && north:231+ return '┴'232+ case west && east && south:233+ return '┬'234+ case south && east:235+ return '┌'236+ case south && west:237+ return '┐'238+ case north && east:239+ return '└'240+ case north && west:241+ return '┘'242+ case north || south:243+ return '│'244+ case west || east:245+ return '─'246+ default:247+ return ' '248+ }249+}250+251+func verticalGapBackground(m Model, regionSet map[point]struct{}, completed map[point]color.Color, x, y int) color.Color {252+ if hasVerticalEdge(m.grid, x, y) || x <= 0 || x >= m.width {253+ return nil254+ }255+ left := point{x: x - 1, y: y}256+ right := point{x: x, y: y}257+ return gapBackground(m, regionSet, completed, left, right)258+}259+260+func horizontalGapBackground(m Model, regionSet map[point]struct{}, completed map[point]color.Color, x, y int) color.Color {261+ if horizontalEdge(m.grid, x, y) || y <= 0 || y >= m.height {262+ return nil263+ }264+ top := point{x: x, y: y - 1}265+ bottom := point{x: x, y: y}266+ return gapBackground(m, regionSet, completed, top, bottom)267+}268+269+func junctionGapBackground(m Model, regionSet map[point]struct{}, completed map[point]color.Color, x, y int) color.Color {270+ if junctionRune(m.grid, x, y) != ' ' {271+ return nil272+ }273+274+ cells := make([]point, 0, 4)275+ if x > 0 && y > 0 {276+ cells = append(cells, point{x: x - 1, y: y - 1})277+ }278+ if x < m.width && y > 0 {279+ cells = append(cells, point{x: x, y: y - 1})280+ }281+ if x > 0 && y < m.height {282+ cells = append(cells, point{x: x - 1, y: y})283+ }284+ if x < m.width && y < m.height {285+ cells = append(cells, point{x: x, y: y})286+ }287+ if len(cells) != 4 {288+ return nil289+ }290+ for i := 1; i < len(cells); i++ {291+ if !sameRegion(m.grid, cells[0], cells[i]) {292+ return nil293+ }294+ }295+ return gapBackground(m, regionSet, completed, cells...)296+}297+298+func gapBackground(m Model, regionSet map[point]struct{}, completed map[point]color.Color, cells ...point) color.Color {299+ p := theme.Current()300+ if len(cells) == 0 {301+ return nil302+ }303+ if anyCellConflict(m.conflicts, cells...) {304+ return game.ConflictBG()305+ }306+ if m.solved {307+ return p.SuccessBG308+ }309+ if bg := completed[cells[0]]; bg != nil {310+ for _, cell := range cells[1:] {311+ if completed[cell] == nil {312+ return nil313+ }314+ }315+ return bg316+ }317+ for _, cell := range cells {318+ if _, ok := regionSet[cell]; !ok {319+ return nil320+ }321+ }322+ if len(regionSet) == 0 {323+ return nil324+ }325+ return p.HighlightBG326+}327+328+func anyCellConflict(conflicts [][]bool, cells ...point) bool {329+ for _, cell := range cells {330+ if conflicts[cell.y][cell.x] {331+ return true332+ }333+ }334+ return false335+}336+337+func sameRegion(g grid, a, b point) bool {338+ return g[a.y][a.x] != 0 && g[a.y][a.x] == g[b.y][b.x]339+}340+341+func completedRegionBackgrounds(g grid, conflicts [][]bool) map[point]color.Color {342+ height := len(g)343+ if height == 0 {344+ return nil345+ }346+ width := len(g[0])347+ visited := make([][]bool, height)348+ for y := range height {349+ visited[y] = make([]bool, width)350+ }351+352+ palette := theme.Current()353+ colors := palette.ThemeColors()354+ if len(colors) == 0 {355+ return nil356+ }357+358+ backgrounds := make(map[point]color.Color)359+ for y := range height {360+ for x := range width {361+ if g[y][x] == 0 || visited[y][x] {362+ continue363+ }364+365+ comp := buildComponent(g, point{x: x, y: y}, visited)366+ if len(comp.cells) != comp.value || componentHasConflict(comp, conflicts) {367+ continue368+ }369+370+ bg := completedRegionColor(comp, colors, palette.Surface)371+ for _, cell := range comp.cells {372+ backgrounds[cell] = bg373+ }374+ }375+ }376+377+ return backgrounds378+}379+380+func componentHasConflict(comp component, conflicts [][]bool) bool {381+ for _, cell := range comp.cells {382+ if conflicts[cell.y][cell.x] {383+ return true384+ }385+ }386+ return false387+}388+389+func completedRegionColor(comp component, colors []color.Color, base color.Color) color.Color {390+ anchor := comp.cells[0]391+ index := (anchor.y*37 + anchor.x*17 + comp.value*13) % len(colors)392+ return theme.Blend(base, colors[index], 0.52)393+}394+395+func statusBarView(showFullHelp bool) string {396+ if showFullHelp {397+ return game.StatusBarStyle().Render("1-9: place bkspc: clear arrows/wasd: move ctrl+n: menu ctrl+r: reset ctrl+h: help")398+ }399+ return game.StatusBarStyle().Render("1-9: place bkspc: clear")400+}
A game/dynamic_grid.go +374 -0
@@ -0,0 +1,374 @@
1+package game2+3+import (4+ "image"5+ "image/color"6+ "strings"7+8+ "charm.land/lipgloss/v2"9+)10+11+const DynamicGridCellWidth = 312+13+const (14+ dynamicGridCellStepX = DynamicGridCellWidth + 115+ dynamicGridCellStepY = 216+)17+18+type DynamicGridMetrics struct {19+ Width int20+ Height int21+ CellWidth int22+}23+24+type DynamicGridBridgeKind int25+26+const (27+ DynamicGridBridgeVertical DynamicGridBridgeKind = iota28+ DynamicGridBridgeHorizontal29+ DynamicGridBridgeJunction30+)31+32+type DynamicGridBridge struct {33+ Kind DynamicGridBridgeKind34+ X int35+ Y int36+ Cells [4]image.Point37+ Count int38+ Zone int39+ Uniform bool40+}41+42+type DynamicGridSpec struct {43+ Width int44+ Height int45+ Solved bool46+ Colors *GridBorderColors47+ Cell func(x, y int) string48+ ZoneAt func(x, y int) int49+ ZoneFill func(zone int) color.Color50+ BridgeFill func(bridge DynamicGridBridge) color.Color51+}52+53+func RenderDynamicGrid(spec DynamicGridSpec) string {54+ rows := make([]string, 0, spec.Height*dynamicGridCellStepY+1)55+ rows = append(rows, dynamicGridBoundaryRow(spec, 0))56+ for y := range spec.Height {57+ rows = append(rows, dynamicGridContentRow(spec, y))58+ rows = append(rows, dynamicGridBoundaryRow(spec, y+1))59+ }60+ return lipgloss.JoinVertical(lipgloss.Left, rows...)61+}62+63+func DynamicGridOrigin(termW, termH int, fullView, title, grid string) (x, y int) {64+ viewWidth := lipgloss.Width(fullView)65+ viewHeight := lipgloss.Height(fullView)66+ if viewWidth > termW {67+ viewWidth = termW68+ }69+ if viewHeight > termH {70+ viewHeight = termH71+ }72+73+ centerX := max((termW-viewWidth)/2, 0)74+ centerY := max((termH-viewHeight)/2, 0)75+76+ titleHeight := strings.Count(title, "\n") + 177+ gridWidth := lipgloss.Width(grid)78+ gridPadLeft := max((viewWidth-gridWidth)/2, 0)79+80+ return centerX + 1 + gridPadLeft, centerY + 1 + titleHeight81+}82+83+func DynamicGridScreenToCell(84+ metrics DynamicGridMetrics,85+ originX, originY, screenX, screenY int,86+ includeSeparators bool,87+) (col, row int, ok bool) {88+ if metrics.Width <= 0 || metrics.Height <= 0 {89+ return 0, 0, false90+ }91+92+ cellWidth := metrics.CellWidth93+ if cellWidth <= 0 {94+ cellWidth = DynamicGridCellWidth95+ }96+97+ lx := screenX - originX98+ ly := screenY - originY99+ if lx < 0 || ly < 0 {100+ return 0, 0, false101+ }102+103+ maxX := (metrics.Width-1)*(cellWidth+1) + (cellWidth - 1)104+ maxY := (metrics.Height - 1) * dynamicGridCellStepY105+ if lx > maxX || ly > maxY {106+ return 0, 0, false107+ }108+109+ if includeSeparators {110+ col = min((lx+cellWidth/2)/(cellWidth+1), metrics.Width-1)111+ row = min((ly+1)/dynamicGridCellStepY, metrics.Height-1)112+ } else {113+ col = lx / (cellWidth + 1)114+ row = ly / dynamicGridCellStepY115+ if lx%(cellWidth+1) >= cellWidth || ly%dynamicGridCellStepY != 0 {116+ return 0, 0, false117+ }118+ }119+120+ if col < 0 || col >= metrics.Width || row < 0 || row >= metrics.Height {121+ return 0, 0, false122+ }123+ return col, row, true124+}125+126+func dynamicGridContentRow(spec DynamicGridSpec, y int) string {127+ var b strings.Builder128+ for x := range spec.Width {129+ bridge := dynamicGridVerticalBridge(spec, x, y)130+ b.WriteString(dynamicGridRenderBorderChar(131+ verticalEdgeRune(dynamicGridHasVerticalEdge(spec, x, y)),132+ dynamicGridColors(spec),133+ spec.Solved,134+ dynamicGridBridgeBackground(spec, bridge),135+ ))136+ if spec.Cell != nil {137+ b.WriteString(spec.Cell(x, y))138+ }139+ }140+141+ bridge := dynamicGridVerticalBridge(spec, spec.Width, y)142+ b.WriteString(dynamicGridRenderBorderChar(143+ verticalEdgeRune(dynamicGridHasVerticalEdge(spec, spec.Width, y)),144+ dynamicGridColors(spec),145+ spec.Solved,146+ dynamicGridBridgeBackground(spec, bridge),147+ ))148+ return b.String()149+}150+151+func dynamicGridBoundaryRow(spec DynamicGridSpec, y int) string {152+ var b strings.Builder153+ for x := 0; x <= spec.Width; x++ {154+ runeValue := dynamicGridJunctionRune(spec, x, y)155+ b.WriteString(dynamicGridRenderBorderChar(156+ runeValue,157+ dynamicGridColors(spec),158+ spec.Solved,159+ dynamicGridBridgeBackground(spec, dynamicGridJunctionBridge(spec, x, y, runeValue)),160+ ))161+ if x == spec.Width {162+ continue163+ }164+165+ ch := ' '166+ if dynamicGridHasHorizontalEdge(spec, x, y) {167+ ch = '─'168+ }169+ b.WriteString(strings.Repeat(170+ dynamicGridRenderBorderChar(171+ ch,172+ dynamicGridColors(spec),173+ spec.Solved,174+ dynamicGridBridgeBackground(spec, dynamicGridHorizontalBridge(spec, x, y)),175+ ),176+ DynamicGridCellWidth,177+ ))178+ }179+ return b.String()180+}181+182+func dynamicGridColors(spec DynamicGridSpec) GridBorderColors {183+ if spec.Colors != nil {184+ return *spec.Colors185+ }186+ return DefaultBorderColors()187+}188+189+func dynamicGridRenderBorderChar(ch rune, colors GridBorderColors, solved bool, bg color.Color) string {190+ fg := colors.BorderFG191+ if solved {192+ fg = colors.SolvedBorderFG193+ }194+ if bg == nil {195+ bg = colors.BackgroundBG196+ if solved {197+ bg = colors.SolvedBG198+ }199+ }200+ return lipgloss.NewStyle().201+ Foreground(fg).202+ Background(bg).203+ Render(string(ch))204+}205+206+func dynamicGridHasHorizontalEdge(spec DynamicGridSpec, x, y int) bool {207+ switch {208+ case y <= 0, y >= spec.Height:209+ return true210+ default:211+ return dynamicGridZoneAt(spec, x, y-1) != dynamicGridZoneAt(spec, x, y)212+ }213+}214+215+func dynamicGridHasVerticalEdge(spec DynamicGridSpec, x, y int) bool {216+ switch {217+ case x <= 0, x >= spec.Width:218+ return true219+ default:220+ return dynamicGridZoneAt(spec, x-1, y) != dynamicGridZoneAt(spec, x, y)221+ }222+}223+224+func dynamicGridJunctionRune(spec DynamicGridSpec, x, y int) rune {225+ north := y > 0 && dynamicGridHasVerticalEdge(spec, x, y-1)226+ south := y < spec.Height && dynamicGridHasVerticalEdge(spec, x, y)227+ west := x > 0 && dynamicGridHasHorizontalEdge(spec, x-1, y)228+ east := x < spec.Width && dynamicGridHasHorizontalEdge(spec, x, y)229+230+ switch {231+ case north && south && west && east:232+ return '┼'233+ case north && south && west:234+ return '┤'235+ case north && south && east:236+ return '├'237+ case west && east && north:238+ return '┴'239+ case west && east && south:240+ return '┬'241+ case south && east:242+ return '┌'243+ case south && west:244+ return '┐'245+ case north && east:246+ return '└'247+ case north && west:248+ return '┘'249+ case north || south:250+ return '│'251+ case west || east:252+ return '─'253+ default:254+ return ' '255+ }256+}257+258+func dynamicGridBridgeBackground(spec DynamicGridSpec, bridge DynamicGridBridge) color.Color {259+ if bridge.Count == 0 {260+ return nil261+ }262+ if spec.BridgeFill != nil {263+ if bg := spec.BridgeFill(bridge); bg != nil {264+ return bg265+ }266+ }267+ if bridge.Uniform && spec.ZoneFill != nil {268+ return spec.ZoneFill(bridge.Zone)269+ }270+ return nil271+}272+273+func dynamicGridVerticalBridge(spec DynamicGridSpec, x, y int) DynamicGridBridge {274+ if dynamicGridHasVerticalEdge(spec, x, y) || x <= 0 || x >= spec.Width {275+ return DynamicGridBridge{276+ Kind: DynamicGridBridgeVertical,277+ X: x,278+ Y: y,279+ }280+ }281+282+ return dynamicGridBridge(283+ spec,284+ DynamicGridBridgeVertical,285+ x,286+ y,287+ image.Pt(x-1, y),288+ image.Pt(x, y),289+ )290+}291+292+func dynamicGridHorizontalBridge(spec DynamicGridSpec, x, y int) DynamicGridBridge {293+ if dynamicGridHasHorizontalEdge(spec, x, y) || y <= 0 || y >= spec.Height {294+ return DynamicGridBridge{295+ Kind: DynamicGridBridgeHorizontal,296+ X: x,297+ Y: y,298+ }299+ }300+301+ return dynamicGridBridge(302+ spec,303+ DynamicGridBridgeHorizontal,304+ x,305+ y,306+ image.Pt(x, y-1),307+ image.Pt(x, y),308+ )309+}310+311+func dynamicGridJunctionBridge(spec DynamicGridSpec, x, y int, runeValue rune) DynamicGridBridge {312+ if runeValue != ' ' {313+ return DynamicGridBridge{314+ Kind: DynamicGridBridgeJunction,315+ X: x,316+ Y: y,317+ }318+ }319+ if x <= 0 || y <= 0 || x >= spec.Width || y >= spec.Height {320+ return DynamicGridBridge{321+ Kind: DynamicGridBridgeJunction,322+ X: x,323+ Y: y,324+ }325+ }326+327+ return dynamicGridBridge(328+ spec,329+ DynamicGridBridgeJunction,330+ x,331+ y,332+ image.Pt(x-1, y-1),333+ image.Pt(x, y-1),334+ image.Pt(x-1, y),335+ image.Pt(x, y),336+ )337+}338+339+func dynamicGridBridge(spec DynamicGridSpec, kind DynamicGridBridgeKind, x, y int, cells ...image.Point) DynamicGridBridge {340+ bridge := DynamicGridBridge{341+ Kind: kind,342+ X: x,343+ Y: y,344+ Count: len(cells),345+ Uniform: len(cells) > 0,346+ }347+348+ for i := range cells {349+ bridge.Cells[i] = cells[i]350+ zone := dynamicGridZoneAt(spec, cells[i].X, cells[i].Y)351+ if i == 0 {352+ bridge.Zone = zone353+ continue354+ }355+ if zone != bridge.Zone {356+ bridge.Uniform = false357+ }358+ }359+ return bridge360+}361+362+func dynamicGridZoneAt(spec DynamicGridSpec, x, y int) int {363+ if spec.ZoneAt == nil {364+ return 0365+ }366+ return spec.ZoneAt(x, y)367+}368+369+func verticalEdgeRune(hasEdge bool) rune {370+ if hasEdge {371+ return '│'372+ }373+ return ' '374+}
A game/dynamic_grid_test.go +153 -0
@@ -0,0 +1,153 @@
1+package game2+3+import (4+ "strings"5+ "testing"6+7+ "charm.land/lipgloss/v2"8+ "github.com/FelineStateMachine/puzzletea/theme"9+ "github.com/charmbracelet/x/ansi"10+)11+12+func TestRenderDynamicGridCenterJunctionRune(t *testing.T) {13+ tests := []struct {14+ name string15+ zones [][]int16+ wantRune rune17+ }{18+ {19+ name: "open interior",20+ zones: [][]int{{0, 0}, {0, 0}},21+ wantRune: ' ',22+ },23+ {24+ name: "vertical wall",25+ zones: [][]int{{0, 1}, {0, 1}},26+ wantRune: '│',27+ },28+ {29+ name: "cross",30+ zones: [][]int{{0, 1}, {2, 3}},31+ wantRune: '┼',32+ },33+ }34+35+ for _, tt := range tests {36+ t.Run(tt.name, func(t *testing.T) {37+ view := RenderDynamicGrid(DynamicGridSpec{38+ Width: 2,39+ Height: 2,40+ Cell: func(_, _ int) string {41+ return " "42+ },43+ ZoneAt: func(x, y int) int {44+ return tt.zones[y][x]45+ },46+ })47+48+ lines := strings.Split(ansi.Strip(view), "\n")49+ got := []rune(lines[2])[4]50+ if got != tt.wantRune {51+ t.Fatalf("center junction = %q, want %q", got, tt.wantRune)52+ }53+ })54+ }55+}56+57+func TestRenderDynamicGridUsesUniformRows(t *testing.T) {58+ view := RenderDynamicGrid(DynamicGridSpec{59+ Width: 3,60+ Height: 3,61+ Cell: func(_, _ int) string {62+ return " "63+ },64+ ZoneAt: func(x, y int) int {65+ return y*10 + x66+ },67+ })68+69+ lines := strings.Split(view, "\n")70+ if got, want := len(lines), 7; got != want {71+ t.Fatalf("line count = %d, want %d", got, want)72+ }73+74+ wantWidth := lipgloss.Width(lines[0])75+ for i, line := range lines {76+ if width := lipgloss.Width(line); width != wantWidth {77+ t.Fatalf("line %d width = %d, want %d", i, width, wantWidth)78+ }79+ }80+}81+82+func TestDynamicGridRenderBorderCharSolvedDefaults(t *testing.T) {83+ got := dynamicGridRenderBorderChar('│', DefaultBorderColors(), true, nil)84+ want := lipgloss.NewStyle().85+ Foreground(theme.Current().SuccessBorder).86+ Background(theme.Current().SuccessBG).87+ Render("│")88+ if got != want {89+ t.Fatal("expected solved border char to use solved colors")90+ }91+}92+93+func TestDynamicGridScreenToCell(t *testing.T) {94+ metrics := DynamicGridMetrics{Width: 3, Height: 3, CellWidth: DynamicGridCellWidth}95+ originX, originY := 10, 2096+97+ tests := []struct {98+ name string99+ screenX int100+ screenY int101+ includeSeparators bool102+ wantCol int103+ wantRow int104+ wantOK bool105+ }{106+ {"cell origin", originX, originY, false, 0, 0, true},107+ {"next cell", originX + DynamicGridCellWidth + 1, originY, false, 1, 0, true},108+ {"separator rejected", originX + DynamicGridCellWidth, originY, false, 0, 0, false},109+ {"separator snaps during drag", originX + DynamicGridCellWidth, originY, true, 1, 0, true},110+ {"outside right", originX + 3*(DynamicGridCellWidth+1), originY, false, 0, 0, false},111+ }112+113+ for _, tt := range tests {114+ t.Run(tt.name, func(t *testing.T) {115+ col, row, ok := DynamicGridScreenToCell(116+ metrics,117+ originX,118+ originY,119+ tt.screenX,120+ tt.screenY,121+ tt.includeSeparators,122+ )123+ if ok != tt.wantOK {124+ t.Fatalf("ok = %v, want %v", ok, tt.wantOK)125+ }126+ if ok && (col != tt.wantCol || row != tt.wantRow) {127+ t.Fatalf("cell = (%d,%d), want (%d,%d)", col, row, tt.wantCol, tt.wantRow)128+ }129+ })130+ }131+}132+133+func TestDynamicGridOriginStableAcrossSecondaryRows(t *testing.T) {134+ grid := RenderDynamicGrid(DynamicGridSpec{135+ Width: 3,136+ Height: 2,137+ Cell: func(_, _ int) string {138+ return " "139+ },140+ ZoneAt: func(_, _ int) int {141+ return 0142+ },143+ })144+ title := TitleBarView("Test", "Mode", false)145+ shortView := ComposeGameViewRows(title, grid, StaticRow("1-9: place"))146+ longView := ComposeGameViewRows(title, grid, StableRow("1-9: place", strings.Repeat("long help ", 6)))147+148+ shortX, shortY := DynamicGridOrigin(120, 40, shortView, title, grid)149+ longX, longY := DynamicGridOrigin(120, 40, longView, title, grid)150+ if shortX != longX || shortY != longY {151+ t.Fatalf("origin changed from (%d,%d) to (%d,%d)", shortX, shortY, longX, longY)152+ }153+}
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 +68 -27
@@ -10,36 +10,15 @@ import (
1010 1111 const cellWidth = 31212 13+const shadedCellDisplay = " █ "14+1315 func cellView(num rune, mark cellMark, isCursor, inCursorRow, inCursorCol, solved, conflict bool) string {1416 p := theme.Current()15- var s lipgloss.Style16- var display string17-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- }17+ s, display := hitoriCellBase(mark, num)3518 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.CursorRight19+ // Priority: cursor+solved > cursor+conflict > cursor > solved > conflict > crosshair > base.20+ if isCursor {21+ s = hitoriCursorStyle(mark, solved, conflict)4322 } else if solved {4423 s = s.Foreground(p.SolvedFG).Background(p.SuccessBG)4524 } else if conflict {
@@ -51,6 +30,68 @@ func cellView(num rune, mark cellMark, isCursor, inCursorRow, inCursorCol, solve
5130 return s.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(display)5231 }5332 33+func hitoriCellBase(mark cellMark, num rune) (lipgloss.Style, string) {34+ p := theme.Current()35+36+ switch mark {37+ case shaded:38+ return lipgloss.NewStyle().39+ Foreground(p.Surface).40+ Background(p.Surface),41+ shadedCellDisplay42+ case circled:43+ return lipgloss.NewStyle().44+ Foreground(p.Info).45+ Background(p.BG),46+ fmt.Sprintf(" %c ", num)47+ default:48+ return lipgloss.NewStyle().49+ Foreground(p.FG).50+ Background(p.BG),51+ fmt.Sprintf(" %c ", num)52+ }53+}54+55+func hitoriCursorStyle(mark cellMark, solved, conflict bool) lipgloss.Style {56+ p := theme.Current()57+ style := lipgloss.NewStyle().Bold(true)58+59+ switch {60+ case solved:61+ style = style.Background(p.SuccessBG)62+ switch mark {63+ case circled:64+ style = style.Foreground(p.Info)65+ case shaded:66+ style = style.Foreground(p.SolvedFG)67+ default:68+ style = style.Foreground(game.CursorFG())69+ }70+ case conflict:71+ style = style.Background(game.ConflictBG()).Underline(true)72+ switch mark {73+ case circled:74+ style = style.Foreground(p.Info)75+ case shaded:76+ style = style.Foreground(game.CursorFG())77+ default:78+ style = style.Foreground(game.CursorFG())79+ }80+ default:81+ style = style.Background(game.CursorBG())82+ switch mark {83+ case circled:84+ style = style.Foreground(p.Info)85+ case shaded:86+ style = style.Foreground(game.CursorFG())87+ default:88+ style = style.Foreground(game.CursorFG())89+ }90+ }91+92+ return style93+}94+5495 func gridView(numbers grid, marks [][]cellMark, c game.Cursor, solved bool, conflicts [][]bool) string {5596 colors := game.DefaultBorderColors()5697
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 +264 -0
@@ -0,0 +1,264 @@
1+package rippleeffect2+3+import (4+ "image/color"5+ "strconv"6+7+ "charm.land/lipgloss/v2"8+ "github.com/FelineStateMachine/puzzletea/game"9+ "github.com/FelineStateMachine/puzzletea/theme"10+)11+12+const cellWidth = game.DynamicGridCellWidth13+14+type visualKind int15+16+const (17+ visualNormal visualKind = iota18+ visualCrosshair19+ visualCage20+ visualCompleted21+ visualConflictCursor22+ visualCursor23+ visualSolved24+ visualConflict25+)26+27+func chooseVisualKind(cursor, solved, conflict, completed, cageHighlight, crosshair bool) visualKind {28+ switch {29+ case cursor && conflict:30+ return visualConflictCursor31+ case conflict:32+ return visualConflict33+ case solved:34+ return visualSolved35+ case cursor:36+ return visualCursor37+ case completed:38+ return visualCompleted39+ case cageHighlight:40+ return visualCage41+ case crosshair:42+ return visualCrosshair43+ default:44+ return visualNormal45+ }46+}47+48+func gridView(m Model) string {49+ activeCage := m.geo.cageGrid[m.cursor.Y][m.cursor.X]50+ completed := completedCageBackgrounds(m)51+ bridgeBG := bridgeBackgrounds(m, completed)52+ return game.RenderDynamicGrid(game.DynamicGridSpec{53+ Width: m.width,54+ Height: m.height,55+ Solved: m.solved,56+ Cell: func(x, y int) string {57+ return cellView(m, x, y, activeCage, completed[m.geo.cageGrid[y][x]])58+ },59+ ZoneAt: func(x, y int) int {60+ return m.geo.cageGrid[y][x]61+ },62+ ZoneFill: func(zone int) color.Color {63+ return bridgeBG[zone]64+ },65+ })66+}67+68+func cellView(m Model, x, y, activeCage int, completedBG color.Color) string {69+ p := theme.Current()70+ kind := chooseVisualKind(71+ x == m.cursor.X && y == m.cursor.Y,72+ m.solved,73+ m.conflicts[y][x],74+ completedBG != nil,75+ m.geo.cageGrid[y][x] == activeCage,76+ y == m.cursor.Y || x == m.cursor.X,77+ )78+79+ style := lipgloss.NewStyle().80+ Width(cellWidth).81+ AlignHorizontal(lipgloss.Center)82+83+ fg := p.FG84+ bg := p.BG85+ text := " · "86+ if value := m.grid[y][x]; value != 0 {87+ text = " " + strconv.Itoa(value) + " "88+ }89+90+ switch kind {91+ case visualConflictCursor:92+ fg = game.CursorFG()93+ bg = game.ConflictBG()94+ text = cursorText(m.grid[y][x])95+ case visualConflict:96+ fg = game.ConflictFG()97+ bg = game.ConflictBG()98+ case visualSolved:99+ fg = p.SolvedFG100+ bg = p.SuccessBG101+ case visualCursor:102+ fg = p.AccentText103+ bg = p.AccentBG104+ text = cursorText(m.grid[y][x])105+ case visualCompleted:106+ bg = completedBG107+ fg = theme.TextOnBG(bg)108+ case visualCage:109+ bg = p.SelectionBG110+ fg = theme.TextOnBG(bg)111+ case visualCrosshair:112+ bg = p.Surface113+ }114+115+ if m.givens[y][x] != 0 {116+ style = style.Bold(true)117+ }118+119+ return style.Foreground(fg).Background(bg).Render(text)120+}121+122+func cursorText(value int) string {123+ if value == 0 {124+ return game.CursorLeft + "·" + game.CursorRight125+ }126+ return game.CursorLeft + strconv.Itoa(value) + game.CursorRight127+}128+129+func completedCageBackgrounds(m Model) map[int]color.Color {130+ palette := theme.Current()131+ colors := palette.ThemeColors()132+ if len(colors) == 0 {133+ return nil134+ }135+136+ backgrounds := make(map[int]color.Color)137+ for cageIdx, cells := range m.geo.cageCells {138+ if !cageCompleted(m, cageIdx, cells) {139+ continue140+ }141+ backgrounds[cageIdx] = completedCageColor(m.geo.cages[cageIdx], colors, palette.Surface)142+ }143+ return backgrounds144+}145+146+func solvedBridgeBackgrounds(m Model) map[int]color.Color {147+ p := theme.Current()148+ backgrounds := make(map[int]color.Color, len(m.geo.cages))149+ for cageIdx := range m.geo.cages {150+ backgrounds[cageIdx] = p.SuccessBG151+ }152+ return backgrounds153+}154+155+func conflictBridgeBackgrounds(m Model) map[int]color.Color {156+ backgrounds := make(map[int]color.Color)157+ for cageIdx, cells := range m.geo.cageCells {158+ for _, cell := range cells {159+ if !m.conflicts[cell.y][cell.x] {160+ continue161+ }162+ backgrounds[cageIdx] = game.ConflictBG()163+ break164+ }165+ }166+ return backgrounds167+}168+169+func bridgeBackgrounds(m Model, completed map[int]color.Color) map[int]color.Color {170+ if m.solved {171+ return solvedBridgeBackgrounds(m)172+ }173+174+ backgrounds := activeCageBridgeBackgrounds(m)175+ for cageIdx, bg := range completed {176+ backgrounds[cageIdx] = bg177+ }178+ for cageIdx, bg := range conflictBridgeBackgrounds(m) {179+ backgrounds[cageIdx] = bg180+ }181+ return backgrounds182+}183+184+func activeCageBridgeBackgrounds(m Model) map[int]color.Color {185+ backgrounds := make(map[int]color.Color, 1)186+ if m.geo == nil || m.width == 0 || m.height == 0 {187+ return backgrounds188+ }189+190+ activeCage := m.geo.cageGrid[m.cursor.Y][m.cursor.X]191+ backgrounds[activeCage] = theme.Current().SelectionBG192+ return backgrounds193+}194+195+func cageCompleted(m Model, cageIdx int, cells []point) bool {196+ size := m.geo.cageSizes[cageIdx]197+ seen := make(map[int]struct{}, size)198+ for _, cell := range cells {199+ if m.conflicts[cell.y][cell.x] {200+ return false201+ }202+ value := m.grid[cell.y][cell.x]203+ if value < 1 || value > size {204+ return false205+ }206+ if _, exists := seen[value]; exists {207+ return false208+ }209+ seen[value] = struct{}{}210+ }211+ return len(seen) == size212+}213+214+func completedCageColor(cage Cage, colors []color.Color, base color.Color) color.Color {215+ if len(colors) == 0 {216+ return nil217+ }218+ first := cage.Cells[0]219+ index := (first.Y*37 + first.X*17 + cage.Size*13 + cage.ID*7) % len(colors)220+ return theme.Blend(base, colors[index], 0.52)221+}222+223+func verticalGapBackground(geo *geometry, backgrounds map[int]color.Color, x, y int) color.Color {224+ if geo == nil || x <= 0 || x >= geo.width || y < 0 || y >= geo.height {225+ return nil226+ }227+ left := geo.cageGrid[y][x-1]228+ right := geo.cageGrid[y][x]229+ if left != right {230+ return nil231+ }232+ return backgrounds[left]233+}234+235+func horizontalGapBackground(geo *geometry, backgrounds map[int]color.Color, x, y int) color.Color {236+ if geo == nil || y <= 0 || y >= geo.height || x < 0 || x >= geo.width {237+ return nil238+ }239+ top := geo.cageGrid[y-1][x]240+ bottom := geo.cageGrid[y][x]241+ if top != bottom {242+ return nil243+ }244+ return backgrounds[top]245+}246+247+func junctionGapBackground(geo *geometry, backgrounds map[int]color.Color, x, y int) color.Color {248+ if geo == nil || x <= 0 || x >= geo.width || y <= 0 || y >= geo.height {249+ return nil250+ }251+252+ cageID := geo.cageGrid[y-1][x-1]253+ if geo.cageGrid[y-1][x] != cageID || geo.cageGrid[y][x-1] != cageID || geo.cageGrid[y][x] != cageID {254+ return nil255+ }256+ return backgrounds[cageID]257+}258+259+func statusBarView(showFullHelp bool) string {260+ if showFullHelp {261+ return game.StatusBarStyle().Render("1-9: place bkspc: clear arrows/wasd: move ctrl+n: menu ctrl+r: reset ctrl+h: help")262+ }263+ return game.StatusBarStyle().Render("1-9: place bkspc: clear")264+}
M shikaku/Export.go +0 -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 +22 -50
@@ -1,31 +1,31 @@
11 package shikaku22 3-import (4- "strings"5-6- "charm.land/lipgloss/v2"7- "github.com/FelineStateMachine/puzzletea/game"8-)3+import "github.com/FelineStateMachine/puzzletea/game"94 105 // screenToGrid converts terminal screen coordinates to grid cell coordinates.116 // Returns (col, row, ok) where ok is false if the click landed outside the grid.127 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- // Shikaku has no separators: simple division by cell dimensions.21- col = lx / cellWidth22- row = ly // cellHeight is 18+ return m.screenToGridAt(screenX, screenY, false)9+}2310 24- if col < 0 || col >= m.puzzle.Width || row < 0 || row >= m.puzzle.Height {25- return 0, 0, false26- }11+func (m *Model) screenToGridDrag(screenX, screenY int) (col, row int, ok bool) {12+ return m.screenToGridAt(screenX, screenY, true)13+}2714 28- return col, row, true15+func (m *Model) screenToGridAt(screenX, screenY int, includeSeparators bool) (col, row int, ok bool) {16+ ox, oy := m.cachedGridOrigin()17+ return game.DynamicGridScreenToCell(18+ game.DynamicGridMetrics{19+ Width: m.puzzle.Width,20+ Height: m.puzzle.Height,21+ CellWidth: cellWidth,22+ },23+ ox,24+ oy,25+ screenX,26+ screenY,27+ includeSeparators,28+ )2929 }3030 3131 // cachedGridOrigin returns the screen position of the top-left corner of
@@ -55,40 +55,12 @@ func (m *Model) cachedGridOrigin() (x, y int) {
5555 // Within the game view the layout is:5656 //5757 // title58-// ┌─────────────┐ <- border (1 line top)59-// │ cell grid │ <- border adds 1 char left60-// └─────────────┘58+// dynamic grid6159 // info6260 // status6361 func (m *Model) gridOrigin() (x, y int) {64- gameView := m.View()65- viewWidth := lipgloss.Width(gameView)66- viewHeight := lipgloss.Height(gameView)67-68- if viewWidth > m.termWidth {69- viewWidth = m.termWidth70- }71-72- // Centering offset applied by the root's lipgloss.Place().73- centerX := max((m.termWidth-viewWidth)/2, 0)74- centerY := max((m.termHeight-viewHeight)/2, 0)75-76- // Measure the title height.7762 solved := m.puzzle.IsSolved()7863 title := game.TitleBarView("Shikaku", m.modeTitle, solved)79- titleHeight := strings.Count(title, "\n") + 180-81- // The grid is rendered inside a border. Measure its outer width to82- // compute horizontal padding from JoinVertical(Center, ...).8364 grid := gridView(*m, solved)84- gridOuterWidth := lipgloss.Width(grid)85- gridPadLeft := max((viewWidth-gridOuterWidth)/2, 0)86-87- // Border adds 1 character on the left and 1 line on top.88- const borderLeft = 189- const borderTop = 190-91- x = centerX + gridPadLeft + borderLeft92- y = centerY + titleHeight + borderTop93- return x, y65+ return game.DynamicGridOrigin(m.termWidth, m.termHeight, m.View(), title, grid)9466 }
M shikaku/puzzle.go +167 -13

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

M shikaku/shikaku_test.go +402 -6

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

M shikaku/style.go +200 -78

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.