clone

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

menu changes, render update for nurikabe

Dami committed 179aeca
commit
179aeca909190755f73c8c4feaa7e8077eecddfd
parent
12dcb68efc27c71c7a0e260a73ef9a785e49509d
author
Dami <dami.etoile@gmail.com>
59 files changed, +1054 -272
MREADME.md3+-
Mapp/keys.go4-
Mapp/update.go5+-
Mapp/update_test.go14+-
Mapp/view.go39++++-
Mfillomino/help.md2+-
Mfillomino/style.go2+-
Mgame/border.go40+++-
Mgame/dynamic_grid.go35+--
Mgame/dynamic_grid_test.go72++++++++
Mgame/style.go2+-
Mhashiwokakero/Model.go2+-
Mhashiwokakero/README.md6+-
Mhashiwokakero/hashiwokakero_test.go22++
Mhashiwokakero/help.md4+-
Mhashiwokakero/keys.go9+-
Mhashiwokakero/style.go6+-
Mhitori/README.md2+-
Mhitori/help.md2+-
Mhitori/style.go2+-
Mlightsout/README.md2+-
Mlightsout/help.md2+-
Mlightsout/style.go2+-
Mnonogram/README.md2+-
Mnonogram/help.md2+-
Mnonogram/style.go2+-
Mnurikabe/Model.go2+-
Mnurikabe/README.md2+-
Mnurikabe/help.md2+-
Mnurikabe/mouse.go58++----
Mnurikabe/nurikabe_test.go280+++++++++++++++++++++++++++++-
Mnurikabe/style.go215+++++++++++++++++------
Mrippleeffect/Gamemode.go89+++++++++-
Mrippleeffect/README.md10+-
Mrippleeffect/generator.go150+++++++++++++++--
Mrippleeffect/help.md2+-
Mrippleeffect/rippleeffect_test.go96++++++++++
Mrippleeffect/style.go2+-
Mshikaku/Model.go13+-
Mshikaku/README.md9+-
Mshikaku/help.md7+-
Mshikaku/keys.go11+-
Mshikaku/shikaku_test.go16+-
Mshikaku/style.go8+-
Mstats/stats.go7+-
Mstats/stats_test.go8+-
Msudoku/README.md2+-
Msudoku/help.md2+-
Msudoku/style.go2+-
Mtakuzu/README.md2+-
Mtakuzu/help.md2+-
Mtakuzu/style.go2+-
Mtakuzuplus/help.md2+-
Mtakuzuplus/style.go2+-
Mwordsearch/README.md6+-
Mwordsearch/help.md6+-
Mwordsearch/keys.go4+-
Mwordsearch/style.go4+-
Mwordsearch/wordsearch_test.go18++
M README.md +1 -2
M app/keys.go +0 -4
M app/update.go +3 -2
M app/update_test.go +7 -7
M app/view.go +35 -4
@@ -195,18 +195,49 @@ func renderModeList(cat game.Category, width int) string {
195195 return ui.DimItemStyle().Render("No modes available.")196196 }197197 198- lines := make([]string, 0, len(cat.Modes))198+ modeNames := make([]string, 0, len(cat.Modes))199199 for _, item := range cat.Modes {200200 mode, ok := item.(game.Mode)201201 if !ok {202202 continue203203 }204- line := lipgloss.NewStyle().Width(width).Render("• " + mode.Title())205- lines = append(lines, line)204+ modeNames = append(modeNames, "• "+mode.Title())206205 }207- if len(lines) == 0 {206+ if len(modeNames) == 0 {208207 return ui.DimItemStyle().Render("No modes available.")209208 }209+210+ if width < 24 || len(modeNames) == 1 {211+ lines := make([]string, 0, len(modeNames))212+ for _, name := range modeNames {213+ lines = append(lines, lipgloss.NewStyle().Width(width).Render(name))214+ }215+ return strings.Join(lines, "\n")216+ }217+218+ rows := (len(modeNames) + 1) / 2219+ leftWidth := max(width/2-1, 1)220+ rightWidth := max(width-leftWidth-2, 1)221+222+ left := make([]string, 0, rows)223+ right := make([]string, 0, rows)224+ for i, name := range modeNames {225+ line := lipgloss.NewStyle().Width(leftWidth).Render(name)226+ if i < rows {227+ left = append(left, line)228+ continue229+ }230+ line = lipgloss.NewStyle().Width(rightWidth).Render(name)231+ right = append(right, line)232+ }233+ for len(right) < len(left) {234+ right = append(right, strings.Repeat(" ", rightWidth))235+ }236+237+ lines := make([]string, 0, rows)238+ for i := range left {239+ lines = append(lines, lipgloss.JoinHorizontal(lipgloss.Top, left[i], " ", right[i]))240+ }210241 return strings.Join(lines, "\n")211242 }212243
M fillomino/help.md +1 -1
M fillomino/style.go +1 -1
M game/border.go +29 -11
@@ -17,6 +17,25 @@ type GridBorderColors struct {
1717 SolvedBG color.Color1818 }1919 20+func borderColors(colors GridBorderColors, solved bool, bg color.Color) (fg color.Color, background color.Color) {21+ fg = colors.BorderFG22+ if solved {23+ fg = colors.SolvedBorderFG24+ }25+26+ background = bg27+ if background == nil {28+ background = colors.BackgroundBG29+ if solved {30+ background = colors.SolvedBG31+ }32+ return fg, background33+ }34+35+ // Tinted bridge/crosshair backgrounds need a contrast-aware foreground.36+ return theme.TextOnBG(background), background37+}38+2039 // DefaultBorderColors returns border colors from the active theme.2140 func DefaultBorderColors() GridBorderColors {2241 p := theme.Current()
@@ -31,13 +50,12 @@ func DefaultBorderColors() GridBorderColors {
3150 3251 // BorderChar renders a single border character with optional crosshair highlighting.3352 func BorderChar(ch string, colors GridBorderColors, solved, highlight bool) string {34- s := lipgloss.NewStyle().Foreground(colors.BorderFG).Background(colors.BackgroundBG)35- if solved {36- s = lipgloss.NewStyle().Foreground(colors.SolvedBorderFG).Background(colors.SolvedBG)37- } else if highlight {38- s = s.Background(colors.CrosshairBG)53+ bg := color.Color(nil)54+ if highlight && !solved {55+ bg = colors.CrosshairBG3956 }40- return s.Render(ch)57+ fg, bg := borderColors(colors, solved, bg)58+ return lipgloss.NewStyle().Foreground(fg).Background(bg).Render(ch)4159 }4260 4361 // HBorderRow builds a horizontal border row (top or bottom) with per-column
@@ -51,12 +69,12 @@ func HBorderRow(w, cursorX, cellWidth int, left, right string, colors GridBorder
5169 }5270 for x := range w {5371 highlight := !solved && x == cursorX54- s := lipgloss.NewStyle().Foreground(colors.BorderFG).Background(colors.BackgroundBG)55- if solved {56- s = lipgloss.NewStyle().Foreground(colors.SolvedBorderFG).Background(colors.SolvedBG)57- } else if highlight {58- s = s.Background(colors.CrosshairBG)72+ bg := color.Color(nil)73+ if highlight {74+ bg = colors.CrosshairBG5975 }76+ fg, bg := borderColors(colors, solved, bg)77+ s := lipgloss.NewStyle().Foreground(fg).Background(bg)6078 parts = append(parts, s.Render(segment))6179 }6280 parts = append(parts, BorderChar(right, colors, solved, false))
M game/dynamic_grid.go +12 -23
@@ -6,7 +6,6 @@ import (
66 "strings"77 88 "charm.land/lipgloss/v2"9- "github.com/FelineStateMachine/puzzletea/theme"109 )1110 1211 const DynamicGridCellWidth = 3
@@ -47,6 +46,8 @@ type DynamicGridSpec struct {
4746 Colors *GridBorderColors4847 Cell func(x, y int) string4948 ZoneAt func(x, y int) int49+ HasVerticalEdge func(x, y int) bool50+ HasHorizontalEdge func(x, y int) bool5051 ZoneFill func(zone int) color.Color5152 BridgeFill func(bridge DynamicGridBridge) color.Color5253 VerticalBridgeText func(x, y int) string
@@ -168,16 +169,7 @@ func dynamicGridColors(spec DynamicGridSpec) GridBorderColors {
168169 }169170 170171 func dynamicGridRenderBorderChar(ch rune, colors GridBorderColors, solved bool, bg color.Color) string {171- fg := colors.BorderFG172- if solved {173- fg = colors.SolvedBorderFG174- }175- if bg == nil {176- bg = colors.BackgroundBG177- if solved {178- bg = colors.SolvedBG179- }180- }172+ fg, bg := borderColors(colors, solved, bg)181173 return lipgloss.NewStyle().182174 Foreground(fg).183175 Background(bg).
@@ -239,18 +231,7 @@ func dynamicGridRenderHorizontalBridge(spec DynamicGridSpec, x, y int, bridge Dy
239231 }240232 241233 func dynamicGridRenderBridgeText(text string, width int, colors GridBorderColors, solved bool, bg color.Color) string {242- fg := colors.BorderFG243- if solved {244- fg = colors.SolvedBorderFG245- }246- if bg == nil {247- bg = colors.BackgroundBG248- if solved {249- bg = colors.SolvedBG250- }251- } else {252- fg = theme.TextOnBG(bg)253- }234+ fg, bg := borderColors(colors, solved, bg)254235 255236 return lipgloss.NewStyle().256237 Width(width).
@@ -261,6 +242,10 @@ func dynamicGridRenderBridgeText(text string, width int, colors GridBorderColors
261242 }262243 263244 func dynamicGridHasHorizontalEdge(spec DynamicGridSpec, x, y int) bool {245+ if spec.HasHorizontalEdge != nil {246+ return spec.HasHorizontalEdge(x, y)247+ }248+264249 switch {265250 case y <= 0, y >= spec.Height:266251 return true
@@ -270,6 +255,10 @@ func dynamicGridHasHorizontalEdge(spec DynamicGridSpec, x, y int) bool {
270255 }271256 272257 func dynamicGridHasVerticalEdge(spec DynamicGridSpec, x, y int) bool {258+ if spec.HasVerticalEdge != nil {259+ return spec.HasVerticalEdge(x, y)260+ }261+273262 switch {274263 case x <= 0, x >= spec.Width:275264 return true
M game/dynamic_grid_test.go +72 -0
@@ -79,6 +79,54 @@ func TestRenderDynamicGridUsesUniformRows(t *testing.T) {
7979 }8080 }8181 82+func TestRenderDynamicGridEdgeOverridesSuppressInteriorBorders(t *testing.T) {83+ view := RenderDynamicGrid(DynamicGridSpec{84+ Width: 2,85+ Height: 2,86+ Cell: func(_, _ int) string {87+ return " "88+ },89+ ZoneAt: func(x, y int) int {90+ return y*10 + x91+ },92+ HasVerticalEdge: func(x, _ int) bool {93+ return x <= 0 || x >= 294+ },95+ HasHorizontalEdge: func(_, y int) bool {96+ return y <= 0 || y >= 297+ },98+ })99+100+ lines := strings.Split(ansi.Strip(view), "\n")101+ if got := []rune(lines[1])[4]; got != ' ' {102+ t.Fatalf("interior vertical bridge rune = %q, want %q", got, ' ')103+ }104+ if got := []rune(lines[2])[4]; got != ' ' {105+ t.Fatalf("interior junction rune = %q, want %q", got, ' ')106+ }107+}108+109+func TestRenderDynamicGridDefaultsToZoneEdgesWithoutOverrides(t *testing.T) {110+ view := RenderDynamicGrid(DynamicGridSpec{111+ Width: 2,112+ Height: 2,113+ Cell: func(_, _ int) string {114+ return " "115+ },116+ ZoneAt: func(x, y int) int {117+ return y*10 + x118+ },119+ })120+121+ lines := strings.Split(ansi.Strip(view), "\n")122+ if got := []rune(lines[1])[4]; got != '│' {123+ t.Fatalf("interior vertical bridge rune = %q, want %q", got, '│')124+ }125+ if got := []rune(lines[2])[4]; got != '┼' {126+ t.Fatalf("interior junction rune = %q, want %q", got, '┼')127+ }128+}129+82130 func TestDynamicGridRenderBorderCharSolvedDefaults(t *testing.T) {83131 got := dynamicGridRenderBorderChar('│', DefaultBorderColors(), true, nil)84132 want := lipgloss.NewStyle().
@@ -90,6 +138,30 @@ func TestDynamicGridRenderBorderCharSolvedDefaults(t *testing.T) {
90138 }91139 }92140 141+func TestDynamicGridRenderBorderCharUsesTextOnTintedBackground(t *testing.T) {142+ bg := theme.Current().Surface143+ got := dynamicGridRenderBorderChar('│', DefaultBorderColors(), false, bg)144+ want := lipgloss.NewStyle().145+ Foreground(theme.TextOnBG(bg)).146+ Background(bg).147+ Render("│")148+ if got != want {149+ t.Fatal("expected tinted border char to use contrast-aware foreground")150+ }151+}152+153+func TestBorderCharHighlightUsesTextOnCrosshairBackground(t *testing.T) {154+ colors := DefaultBorderColors()155+ got := BorderChar("│", colors, false, true)156+ want := lipgloss.NewStyle().157+ Foreground(theme.TextOnBG(colors.CrosshairBG)).158+ Background(colors.CrosshairBG).159+ Render("│")160+ if got != want {161+ t.Fatal("expected highlighted border char to use contrast-aware foreground")162+ }163+}164+93165 func TestDynamicGridScreenToCell(t *testing.T) {94166 metrics := DynamicGridMetrics{Width: 3, Height: 3, CellWidth: DynamicGridCellWidth}95167 originX, originY := 10, 20
M game/style.go +1 -1
M hashiwokakero/Model.go +1 -1
M hashiwokakero/README.md +3 -3
M hashiwokakero/hashiwokakero_test.go +22 -0
M hashiwokakero/help.md +2 -2
M hashiwokakero/keys.go +2 -7
M hashiwokakero/style.go +3 -3
M hitori/README.md +1 -1
M hitori/help.md +1 -1
M hitori/style.go +1 -1
M lightsout/README.md +1 -1
M lightsout/help.md +1 -1
M lightsout/style.go +1 -1
M nonogram/README.md +1 -1
M nonogram/help.md +1 -1
M nonogram/style.go +1 -1
M nurikabe/Model.go +1 -1
M nurikabe/README.md +1 -1
M nurikabe/help.md +1 -1
M nurikabe/mouse.go +21 -37
@@ -1,29 +1,33 @@
11 package nurikabe22 33 import (4- "strings"5-6- "charm.land/lipgloss/v2"74 "github.com/FelineStateMachine/puzzletea/game"85 )96 107 // screenToGrid converts terminal screen coordinates to grid cell coordinates.118 // Returns (col, row, ok) where ok is false if click landed outside a cell.129 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- }10+ return m.screenToGridAt(screenX, screenY, false)11+}1912 20- col = lx / cellWidth21- row = ly13+func (m *Model) screenToGridDrag(screenX, screenY int) (col, row int, ok bool) {14+ return m.screenToGridAt(screenX, screenY, true)15+}2216 23- if col < 0 || col >= m.width || row < 0 || row >= m.height {24- return 0, 0, false25- }26- return col, row, true17+func (m *Model) screenToGridAt(screenX, screenY int, includeSeparators bool) (col, row int, ok bool) {18+ ox, oy := m.cachedGridOrigin()19+ return game.DynamicGridScreenToCell(20+ game.DynamicGridMetrics{21+ Width: m.width,22+ Height: m.height,23+ CellWidth: cellWidth,24+ },25+ ox,26+ oy,27+ screenX,28+ screenY,29+ includeSeparators,30+ )2731 }2832 2933 func (m *Model) cachedGridOrigin() (x, y int) {
@@ -37,27 +41,7 @@ func (m *Model) cachedGridOrigin() (x, y int) {
3741 }3842 3943 func (m *Model) gridOrigin() (x, y int) {40- gameView := m.View()41- viewWidth := lipgloss.Width(gameView)42- viewHeight := lipgloss.Height(gameView)43-44- if viewWidth > m.termWidth {45- viewWidth = m.termWidth46- }47-48- centerX := max((m.termWidth-viewWidth)/2, 0)49- centerY := max((m.termHeight-viewHeight)/2, 0)50-5144 title := game.TitleBarView("Nurikabe", m.modeTitle, m.solved)52- titleHeight := strings.Count(title, "\n") + 153-54- gridOuterWidth := lipgloss.Width(gridView(*m))55- gridPadLeft := max((viewWidth-gridOuterWidth)/2, 0)56-57- const borderLeft = 158- const borderTop = 159-60- x = centerX + gridPadLeft + borderLeft61- y = centerY + titleHeight + borderTop62- return x, y45+ grid := gridView(*m)46+ return game.DynamicGridOrigin(m.termWidth, m.termHeight, m.View(), title, grid)6347 }
M nurikabe/nurikabe_test.go +268 -12
@@ -3,13 +3,18 @@ package nurikabe
33 import (44 "context"55 "errors"6+ "image"7+ "image/color"68 "math/rand/v2"79 "runtime"10+ "strings"811 "testing"912 "time"1013 1114 tea "charm.land/bubbletea/v2"1215 "github.com/FelineStateMachine/puzzletea/game"16+ "github.com/FelineStateMachine/puzzletea/theme"17+ "github.com/charmbracelet/x/ansi"1318 )1419 1520 func makeClues(rows ...[]int) clueGrid {
@@ -252,6 +257,24 @@ func pointToCursor(x, y int) game.Cursor {
252257 return game.Cursor{X: x, Y: y}253258 }254259 260+func cellPoint(originX, originY, col, row int) (x, y int) {261+ return originX + col*(cellWidth+1), originY + row*2262+}263+264+func separatorPoint(originX, originY, col, row int) (x, y int) {265+ x, y = cellPoint(originX, originY, col, row)266+ return x - 1, y267+}268+269+func sameColor(left, right color.Color) bool {270+ if left == nil || right == nil {271+ return left == nil && right == nil272+ }273+ lr, lg, lb, la := left.RGBA()274+ rr, rg, rb, ra := right.RGBA()275+ return lr == rr && lg == rg && lb == rb && la == ra276+}277+255278 // --- Model input behavior (P1) ---256279 257280 func TestModelKeyboardUpdate(t *testing.T) {
@@ -303,12 +326,14 @@ func TestModelMouseDragPaint(t *testing.T) {
303326 next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})304327 m = next.(Model)305328 ox, oy := m.gridOrigin()329+ clickX, clickY := cellPoint(ox, oy, 1, 1)330+ dragX, dragY := separatorPoint(ox, oy, 2, 1)306331 307- click := tea.MouseClickMsg{X: ox + cellWidth, Y: oy + 1, Button: tea.MouseLeft}332+ click := tea.MouseClickMsg{X: clickX, Y: clickY, Button: tea.MouseLeft}308333 next, _ = m.Update(click)309334 m = next.(Model)310335 311- drag := tea.MouseMotionMsg{X: ox + 2*cellWidth, Y: oy + 1, Button: tea.MouseLeft}336+ drag := tea.MouseMotionMsg{X: dragX, Y: dragY, Button: tea.MouseLeft}312337 next, _ = m.Update(drag)313338 m = next.(Model)314339
@@ -337,8 +362,7 @@ func TestModelMouseClickMirrorsKeyboard(t *testing.T) {
337362 m = next.(Model)338363 ox, oy := m.gridOrigin()339364 340- cellX := ox + cellWidth341- cellY := oy + 1365+ cellX, cellY := cellPoint(ox, oy, 1, 1)342366 343367 next, _ = m.Update(tea.MouseClickMsg{X: cellX, Y: cellY, Button: tea.MouseLeft})344368 m = next.(Model)
@@ -388,8 +412,7 @@ func TestModelMouseRightClickTogglesIsland(t *testing.T) {
388412 m = next.(Model)389413 ox, oy := m.gridOrigin()390414 391- cellX := ox + cellWidth392- cellY := oy + 1415+ cellX, cellY := cellPoint(ox, oy, 1, 1)393416 394417 next, _ = m.Update(tea.MouseClickMsg{X: cellX, Y: cellY, Button: tea.MouseRight})395418 m = next.(Model)
@@ -420,11 +443,13 @@ func TestModelMouseRightDragPaintIsland(t *testing.T) {
420443 next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})421444 m = next.(Model)422445 ox, oy := m.gridOrigin()446+ clickX, clickY := cellPoint(ox, oy, 1, 1)447+ dragX, dragY := separatorPoint(ox, oy, 2, 1)423448 424- next, _ = m.Update(tea.MouseClickMsg{X: ox + cellWidth, Y: oy + 1, Button: tea.MouseRight})449+ next, _ = m.Update(tea.MouseClickMsg{X: clickX, Y: clickY, Button: tea.MouseRight})425450 m = next.(Model)426451 427- next, _ = m.Update(tea.MouseMotionMsg{X: ox + 2*cellWidth, Y: oy + 1, Button: tea.MouseRight})452+ next, _ = m.Update(tea.MouseMotionMsg{X: dragX, Y: dragY, Button: tea.MouseRight})428453 m = next.(Model)429454 430455 next, _ = m.Update(tea.MouseReleaseMsg{})
@@ -455,11 +480,13 @@ func TestModelMouseRightDragClearsIsland(t *testing.T) {
455480 next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})456481 m = next.(Model)457482 ox, oy := m.gridOrigin()483+ clickX, clickY := cellPoint(ox, oy, 1, 1)484+ dragX, dragY := separatorPoint(ox, oy, 2, 1)458485 459- next, _ = m.Update(tea.MouseClickMsg{X: ox + cellWidth, Y: oy + 1, Button: tea.MouseRight})486+ next, _ = m.Update(tea.MouseClickMsg{X: clickX, Y: clickY, Button: tea.MouseRight})460487 m = next.(Model)461488 462- next, _ = m.Update(tea.MouseMotionMsg{X: ox + 2*cellWidth, Y: oy + 1, Button: tea.MouseRight})489+ next, _ = m.Update(tea.MouseMotionMsg{X: dragX, Y: dragY, Button: tea.MouseRight})463490 m = next.(Model)464491 465492 next, _ = m.Update(tea.MouseReleaseMsg{})
@@ -488,11 +515,13 @@ func TestModelMouseLeftDragPaintSeaFromSea(t *testing.T) {
488515 next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})489516 m = next.(Model)490517 ox, oy := m.gridOrigin()518+ clickX, clickY := cellPoint(ox, oy, 1, 1)519+ dragX, dragY := separatorPoint(ox, oy, 2, 1)491520 492- next, _ = m.Update(tea.MouseClickMsg{X: ox + cellWidth, Y: oy + 1, Button: tea.MouseLeft})521+ next, _ = m.Update(tea.MouseClickMsg{X: clickX, Y: clickY, Button: tea.MouseLeft})493522 m = next.(Model)494523 495- next, _ = m.Update(tea.MouseMotionMsg{X: ox + 2*cellWidth, Y: oy + 1, Button: tea.MouseLeft})524+ next, _ = m.Update(tea.MouseMotionMsg{X: dragX, Y: dragY, Button: tea.MouseLeft})496525 m = next.(Model)497526 498527 next, _ = m.Update(tea.MouseReleaseMsg{})
@@ -530,6 +559,233 @@ func TestHelpToggleInvalidatesOriginCache(t *testing.T) {
530559 }531560 }532561 562+func TestWindowSizeInvalidatesOriginCache(t *testing.T) {563+ mode := NewMode("Mini", "test", 3, 3, 0.3, 5)564+ p := Puzzle{Width: 3, Height: 3, Clues: makeClues(565+ []int{2, 0, 0},566+ []int{0, 0, 0},567+ []int{0, 0, 1},568+ )}569+ g, err := New(mode, p)570+ if err != nil {571+ t.Fatalf("New error: %v", err)572+ }573+ m := g.(Model)574+ m.originValid = true575+ m.originX = 5576+ m.originY = 7577+578+ next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})579+ got := next.(Model)580+581+ if got.originValid {582+ t.Fatal("expected origin cache to be invalidated")583+ }584+}585+586+func TestResetInvalidatesOriginCache(t *testing.T) {587+ mode := NewMode("Mini", "test", 3, 3, 0.3, 5)588+ p := Puzzle{Width: 3, Height: 3, Clues: makeClues(589+ []int{2, 0, 0},590+ []int{0, 0, 0},591+ []int{0, 0, 1},592+ )}593+ g, err := New(mode, p)594+ if err != nil {595+ t.Fatalf("New error: %v", err)596+ }597+ m := g.(Model)598+ m.originValid = true599+600+ got := m.Reset().(Model)601+ if got.originValid {602+ t.Fatal("expected reset to invalidate origin cache")603+ }604+}605+606+func TestSolvedStateTransitionInvalidatesOriginCache(t *testing.T) {607+ mode := NewMode("Mini", "test", 5, 5, 0.3, 5)608+ p := uniquePuzzleFixture()609+ g, err := New(mode, p)610+ if err != nil {611+ t.Fatalf("New error: %v", err)612+ }613+ m := g.(Model)614+ m.originValid = true615+ m.marks = makeMarksFromClues(p.Clues)616+617+ m.afterBoardChange()618+619+ if !m.solved {620+ t.Fatal("expected puzzle to be solved")621+ }622+ if m.originValid {623+ t.Fatal("expected solved transition to invalidate origin cache")624+ }625+}626+627+func TestScreenToGridRejectsSeparatorClicks(t *testing.T) {628+ mode := NewMode("Mini", "test", 3, 3, 0.3, 5)629+ p := Puzzle{Width: 3, Height: 3, Clues: makeClues(630+ []int{2, 0, 0},631+ []int{0, 0, 0},632+ []int{0, 0, 1},633+ )}634+ g, err := New(mode, p)635+ if err != nil {636+ t.Fatalf("New error: %v", err)637+ }638+ m := g.(Model)639+640+ next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})641+ m = next.(Model)642+ ox, oy := m.gridOrigin()643+ separatorX, separatorY := separatorPoint(ox, oy, 2, 1)644+645+ if _, _, ok := m.screenToGrid(separatorX, separatorY); ok {646+ t.Fatal("expected separator click to miss")647+ }648+}649+650+func TestScreenToGridDragSnapsAcrossSeparator(t *testing.T) {651+ mode := NewMode("Mini", "test", 3, 3, 0.3, 5)652+ p := Puzzle{Width: 3, Height: 3, Clues: makeClues(653+ []int{2, 0, 0},654+ []int{0, 0, 0},655+ []int{0, 0, 1},656+ )}657+ g, err := New(mode, p)658+ if err != nil {659+ t.Fatalf("New error: %v", err)660+ }661+ m := g.(Model)662+663+ next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})664+ m = next.(Model)665+ ox, oy := m.gridOrigin()666+ separatorX, separatorY := separatorPoint(ox, oy, 2, 1)667+668+ col, row, ok := m.screenToGridDrag(separatorX, separatorY)669+ if !ok {670+ t.Fatal("expected separator drag to snap to adjacent cell")671+ }672+ if col != 2 || row != 1 {673+ t.Fatalf("drag snapped to (%d,%d), want (2,1)", col, row)674+ }675+}676+677+func TestGridViewUsesDynamicGridWithoutInteriorSeparators(t *testing.T) {678+ mode := NewMode("Mini", "test", 3, 3, 0.3, 5)679+ p := Puzzle{Width: 3, Height: 3, Clues: makeClues(680+ []int{2, 0, 0},681+ []int{0, 0, 0},682+ []int{0, 0, 1},683+ )}684+ g, err := New(mode, p)685+ if err != nil {686+ t.Fatalf("New error: %v", err)687+ }688+ m := g.(Model)689+ m.cursor = pointToCursor(1, 1)690+ m.setCellAtCursor(seaCell)691+ m.cursor = pointToCursor(2, 1)692+ m.setCellAtCursor(islandCell)693+694+ lines := strings.Split(ansi.Strip(gridView(m)), "\n")695+ if got, want := len(lines), m.height*2+1; got != want {696+ t.Fatalf("line count = %d, want %d", got, want)697+ }698+ if !strings.HasPrefix(lines[0], "┌") || !strings.HasSuffix(lines[0], "┐") {699+ t.Fatal("expected top border")700+ }701+ last := lines[len(lines)-1]702+ if !strings.HasPrefix(last, "└") || !strings.HasSuffix(last, "┘") {703+ t.Fatal("expected bottom border")704+ }705+ for _, idx := range []int{1, 2, 3, 4, 5} {706+ line := lines[idx]707+ if strings.Count(line, "│") != 2 {708+ t.Fatalf("line %d = %q, want only outer vertical borders", idx, line)709+ }710+ }711+ for _, idx := range []int{2, 4} {712+ if strings.Contains(lines[idx], "─") {713+ t.Fatalf("line %d = %q, want no interior horizontal separators", idx, lines[idx])714+ }715+ }716+}717+718+func TestBridgeFillBlendsMixedTerrainBackgrounds(t *testing.T) {719+ mode := NewMode("Mini", "test", 3, 1, 0.3, 5)720+ p := Puzzle{Width: 3, Height: 1, Clues: makeClues(721+ []int{1, 0, 0},722+ )}723+ g, err := New(mode, p)724+ if err != nil {725+ t.Fatalf("New error: %v", err)726+ }727+ m := g.(Model)728+ m.cursor = pointToCursor(1, 0)729+ m.setCellAtCursor(seaCell)730+ m.cursor = pointToCursor(2, 0)731+732+ got := bridgeFill(m, game.DynamicGridBridge{733+ Kind: game.DynamicGridBridgeVertical,734+ X: 1,735+ Y: 0,736+ Count: 2,737+ Uniform: false,738+ Cells: [4]image.Point{739+ {X: 0, Y: 0},740+ {X: 1, Y: 0},741+ },742+ })743+744+ palette := theme.Current()745+ want := color.NRGBA{}746+ land := theme.Blend(palette.BG, palette.Success, 0.45)747+ sea := theme.Blend(palette.BG, palette.Secondary, 0.24)748+ lr, lg, lb, la := land.RGBA()749+ sr, sg, sb, sa := sea.RGBA()750+ want.R = uint8(((lr >> 8) + (sr >> 8)) / 2)751+ want.G = uint8(((lg >> 8) + (sg >> 8)) / 2)752+ want.B = uint8(((lb >> 8) + (sb >> 8)) / 2)753+ want.A = uint8(((la >> 8) + (sa >> 8)) / 2)754+755+ if !sameColor(got, want) {756+ t.Fatal("expected mixed land/sea bridge to use blended background")757+ }758+}759+760+func TestBridgeFillDoesNotExpandCursorBackground(t *testing.T) {761+ mode := NewMode("Mini", "test", 3, 1, 0.3, 5)762+ p := Puzzle{Width: 3, Height: 1, Clues: makeClues(763+ []int{1, 0, 0},764+ )}765+ g, err := New(mode, p)766+ if err != nil {767+ t.Fatalf("New error: %v", err)768+ }769+ m := g.(Model)770+ m.cursor = pointToCursor(1, 0)771+ m.setCellAtCursor(seaCell)772+ got := bridgeFill(m, game.DynamicGridBridge{773+ Kind: game.DynamicGridBridgeVertical,774+ X: 1,775+ Y: 0,776+ Count: 2,777+ Uniform: false,778+ Cells: [4]image.Point{779+ {X: 0, Y: 0},780+ {X: 1, Y: 0},781+ },782+ })783+784+ if sameColor(got, game.CursorBG()) {785+ t.Fatal("expected cursor color to stay inside the cell body")786+ }787+}788+533789 // --- Seeded generation determinism (P1) ---534790 535791 func TestGenerateSeededDeterministicConcurrent(t *testing.T) {
M nurikabe/style.go +161 -54
@@ -2,92 +2,199 @@ package nurikabe
22 33 import (44 "fmt"5+ "image/color"56 67 "charm.land/lipgloss/v2"78 "github.com/FelineStateMachine/puzzletea/game"89 "github.com/FelineStateMachine/puzzletea/theme"910 )1011 11-const cellWidth = 312+const cellWidth = game.DynamicGridCellWidth1213 13-func gridBorderStyle() lipgloss.Style {14- p := theme.Current()15- return lipgloss.NewStyle().16- Border(lipgloss.RoundedBorder()).17- BorderForeground(p.Border).18- BorderBackground(p.BG)14+type cellVisual struct {15+ text string16+ fg color.Color17+ bg color.Color18+ bridgeBG color.Color19+ bold bool20+ kind cellVisualKind1921 }2022 21-func gridBorderSolvedStyle() lipgloss.Style {22- p := theme.Current()23- return lipgloss.NewStyle().24- Border(lipgloss.RoundedBorder()).25- BorderForeground(p.SuccessBorder).26- BorderBackground(p.SuccessBG)27-}23+type cellVisualKind int24+25+const (26+ cellVisualLand cellVisualKind = iota27+ cellVisualSea28+ cellVisualCursor29+ cellVisualSolved30+ cellVisualConflict31+)2832 29-func cellStyle(c cellState, clue int, isCursor, solved, conflict, inSeaSquare bool) (lipgloss.Style, string) {33+func resolveCellVisual(m Model, x, y int) cellVisual {3034 p := theme.Current()31- display := " "3235 landBg := theme.Blend(p.BG, p.Success, 0.45)33- style := lipgloss.NewStyle().Background(landBg).Foreground(theme.TextOnBG(landBg))36+ seaBg := theme.Blend(p.BG, p.Secondary, 0.24)37+ c := m.marks[y][x]38+ clue := m.clues[y][x]39+ isCursor := x == m.cursor.X && y == m.cursor.Y40+ conflict := m.conflicts[y][x]41+ inSeaSquare := isSeaSquareCell(m.marks, x, y)42+ visual := cellVisual{43+ text: " ",44+ fg: theme.TextOnBG(landBg),45+ bg: landBg,46+ bridgeBG: landBg,47+ kind: cellVisualLand,48+ }3449 3550 switch {3651 case clue > 0:37- display = fmt.Sprintf("%2d ", clue)52+ visual.text = fmt.Sprintf("%2d ", clue)3853 if clue < 10 {39- display = fmt.Sprintf(" %d ", clue)54+ visual.text = fmt.Sprintf(" %d ", clue)4055 }41-42- style = lipgloss.NewStyle().43- Foreground(p.Info).44- Background(landBg).45- Bold(true)56+ visual.fg = p.Info57+ visual.bold = true4658 case c == seaCell:47- display = " ~ "59+ visual.text = " ~ "4860 if inSeaSquare {49- display = " @ "61+ visual.text = " @ "5062 }51- seaBg := theme.Blend(p.BG, p.Secondary, 0.24)52- style = style.Background(seaBg).Foreground(theme.TextOnBG(seaBg))63+ visual.bg = seaBg64+ visual.bridgeBG = seaBg65+ visual.fg = theme.TextOnBG(seaBg)66+ visual.kind = cellVisualSea5367 case c == islandCell:54- display = " \u00b7 "55- style = style.Background(landBg).Foreground(theme.TextOnBG(landBg))68+ visual.text = " \u00b7 "69+ }70+71+ switch {72+ case conflict:73+ visual.bg = game.ConflictBG()74+ visual.bridgeBG = game.ConflictBG()75+ visual.fg = game.ConflictFG()76+ visual.kind = cellVisualConflict77+ case m.solved:78+ visual.bg = p.SuccessBG79+ visual.bridgeBG = p.SuccessBG80+ visual.fg = theme.TextOnBG(p.SuccessBG)81+ visual.kind = cellVisualSolved82+ case isCursor:83+ visual.bg = game.CursorBG()84+ visual.fg = game.CursorFG()85+ visual.kind = cellVisualCursor86+ }87+88+ return visual89+}90+91+func renderCellVisual(visual cellVisual) string {92+ style := lipgloss.NewStyle().93+ Width(cellWidth).94+ AlignHorizontal(lipgloss.Center).95+ Foreground(visual.fg).96+ Background(visual.bg)97+ if visual.bold {98+ style = style.Bold(true)99+ }100+ return style.Render(visual.text)101+}102+103+func colorsMatch(left, right color.Color) bool {104+ if left == nil || right == nil {105+ return left == nil && right == nil56106 }107+ lr, lg, lb, la := left.RGBA()108+ rr, rg, rb, ra := right.RGBA()109+ return lr == rr && lg == rg && lb == rb && la == ra110+}57111 58- if solved {59- style = style.Background(p.SuccessBG).Foreground(theme.TextOnBG(p.SuccessBG))112+func dominantBridgeBackground(visuals []cellVisual) color.Color {113+ for _, kind := range []cellVisualKind{114+ cellVisualConflict,115+ cellVisualSolved,116+ cellVisualCursor,117+ } {118+ for _, visual := range visuals {119+ if visual.kind == kind {120+ return visual.bridgeBG121+ }122+ }60123 }61- if conflict {62- style = style.Foreground(game.ConflictFG()).Background(game.ConflictBG())124+ return nil125+}126+127+func blendBridgeBackgrounds(visuals []cellVisual) color.Color {128+ if len(visuals) == 0 {129+ return nil63130 }64- if isCursor && solved {65- style = game.CursorSolvedStyle()66- } else if isCursor {67- style = game.CursorStyle()131+132+ var rSum, gSum, bSum, aSum uint32133+ for _, visual := range visuals {134+ r, g, b, a := visual.bridgeBG.RGBA()135+ rSum += uint32(r >> 8)136+ gSum += uint32(g >> 8)137+ bSum += uint32(b >> 8)138+ aSum += uint32(a >> 8)68139 }69140 70- return style.Width(cellWidth).AlignHorizontal(lipgloss.Center), display141+ count := uint32(len(visuals))142+ return color.NRGBA{143+ R: uint8(rSum / count),144+ G: uint8(gSum / count),145+ B: uint8(bSum / count),146+ A: uint8(aSum / count),147+ }71148 }72149 73-func gridView(m Model) string {74- rows := make([]string, m.height)75- for y := range m.height {76- cells := make([]string, m.width)77- for x := range m.width {78- isCursor := x == m.cursor.X && y == m.cursor.Y79- inSeaSquare := isSeaSquareCell(m.marks, x, y)80- style, display := cellStyle(m.marks[y][x], m.clues[y][x], isCursor, m.solved, m.conflicts[y][x], inSeaSquare)81- cells[x] = style.Render(display)150+func bridgeFill(m Model, bridge game.DynamicGridBridge) color.Color {151+ if bridge.Count == 0 {152+ return nil153+ }154+155+ visuals := make([]cellVisual, 0, bridge.Count)156+ for i := 0; i < bridge.Count; i++ {157+ cell := bridge.Cells[i]158+ visuals = append(visuals, resolveCellVisual(m, cell.X, cell.Y))159+ }160+161+ bg := visuals[0].bridgeBG162+ allMatch := true163+ for _, visual := range visuals[1:] {164+ if !colorsMatch(bg, visual.bridgeBG) {165+ allMatch = false166+ break82167 }83- rows[y] = lipgloss.JoinHorizontal(lipgloss.Top, cells...)168+ }169+ if allMatch {170+ return bg84171 }85172 86- content := lipgloss.JoinVertical(lipgloss.Left, rows...)87- if m.solved {88- return gridBorderSolvedStyle().Render(content)173+ if dominant := dominantBridgeBackground(visuals); dominant != nil {174+ return dominant89175 }90- return gridBorderStyle().Render(content)176+177+ return blendBridgeBackgrounds(visuals)178+}179+180+func gridView(m Model) string {181+ return game.RenderDynamicGrid(game.DynamicGridSpec{182+ Width: m.width,183+ Height: m.height,184+ Solved: m.solved,185+ Cell: func(x, y int) string {186+ return renderCellVisual(resolveCellVisual(m, x, y))187+ },188+ HasVerticalEdge: func(x, _ int) bool {189+ return x <= 0 || x >= m.width190+ },191+ HasHorizontalEdge: func(_, y int) bool {192+ return y <= 0 || y >= m.height193+ },194+ BridgeFill: func(bridge game.DynamicGridBridge) color.Color {195+ return bridgeFill(m, bridge)196+ },197+ })91198 }92199 93200 // isSeaSquareCell reports whether (x,y) is part of any 2x2 sea block.
@@ -123,7 +230,7 @@ func isSeaSquareCell(marks grid, x, y int) bool {
123230 124231 func statusBarView(showFullHelp bool) string {125232 if showFullHelp {126- return game.StatusBarStyle().Render("arrows/wasd: move x/LMB: sea z/RMB: island bkspc: clear ctrl+n: menu ctrl+r: reset ctrl+h: help")233+ return game.StatusBarStyle().Render("arrows/wasd: move x/LMB: sea z/RMB: island bkspc: clear esc: menu ctrl+r: reset ctrl+h: help")127234 }128235 return game.StatusBarStyle().Render("x/LMB: sea z/RMB: island bkspc: clear")129236 }
M rippleeffect/Gamemode.go +82 -7
@@ -16,6 +16,7 @@ type Mode struct {
1616 Size int1717 MaxCage int1818 GivenRatio float6419+ profile generationProfile1920 }2021 2122 var (
@@ -25,16 +26,21 @@ var (
2526 )2627 2728 func NewMode(title, description string, size, maxCage int, givenRatio float64) Mode {29+ return NewModeWithProfile(title, description, size, maxCage, givenRatio, defaultGenerationProfile(maxCage))30+}31+32+func NewModeWithProfile(title, description string, size, maxCage int, givenRatio float64, profile generationProfile) Mode {2833 return Mode{2934 BaseMode: game.NewBaseMode(title, description),3035 Size: size,3136 MaxCage: maxCage,3237 GivenRatio: givenRatio,38+ profile: profile,3339 }3440 }3541 3642 func (m Mode) Spawn() (game.Gamer, error) {37- puzzle, err := GeneratePuzzle(m.Size, m.Size, m.MaxCage, m.GivenRatio)43+ puzzle, err := m.generatePuzzle()3844 if err != nil {3945 return nil, err4046 }
@@ -42,7 +48,7 @@ func (m Mode) Spawn() (game.Gamer, error) {
4248 }4349 4450 func (m Mode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) {45- puzzle, err := GeneratePuzzleSeeded(m.Size, m.Size, m.MaxCage, m.GivenRatio, rng)51+ puzzle, err := m.generatePuzzleSeeded(rng)4652 if err != nil {4753 return nil, err4854 }
@@ -50,11 +56,71 @@ func (m Mode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) {
5056 }5157 5258 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),59+ NewModeWithProfile(60+ "Mini 5x5",61+ "Compact cages with extra anchors for quick local reads.",62+ 5,63+ 3,64+ 0.72,65+ generationProfile{66+ cageWeights: []int{0, 5, 6, 3},67+ frontierSamples: 3,68+ shapeBias: shapeBiasCompact,69+ minGivensByCage: []int{0, 1, 1, 2},70+ },71+ ),72+ NewModeWithProfile(73+ "Easy 6x6",74+ "Gentle spacing logic with compact cages and steady clues.",75+ 6,76+ 3,77+ 0.67,78+ generationProfile{79+ cageWeights: []int{0, 3, 5, 4},80+ frontierSamples: 3,81+ shapeBias: shapeBiasCompact,82+ minGivensByCage: []int{0, 1, 1, 2},83+ },84+ ),85+ NewModeWithProfile(86+ "Medium 7x7",87+ "Mixed cage shapes with a balanced clue spread.",88+ 7,89+ 4,90+ 0.62,91+ generationProfile{92+ cageWeights: []int{0, 2, 4, 5, 3},93+ frontierSamples: 2,94+ shapeBias: shapeBiasNeutral,95+ minGivensByCage: []int{0, 1, 1, 1, 1},96+ },97+ ),98+ NewModeWithProfile(99+ "Hard 8x8",100+ "Longer cages and lighter anchors create broader ripple scans.",101+ 8,102+ 4,103+ 0.58,104+ generationProfile{105+ cageWeights: []int{0, 1, 2, 4, 5},106+ frontierSamples: 3,107+ shapeBias: shapeBiasWinding,108+ minGivensByCage: []int{0, 1, 1, 1, 1},109+ },110+ ),111+ NewModeWithProfile(112+ "Expert 9x9",113+ "Sparser anchors and winding large cages push global deductions.",114+ 9,115+ 5,116+ 0.54,117+ generationProfile{118+ cageWeights: []int{0, 1, 1, 3, 4, 5},119+ frontierSamples: 4,120+ shapeBias: shapeBiasWinding,121+ minGivensByCage: []int{0, 1, 1, 1, 1, 1},122+ },123+ ),58124 }59125 60126 var DailyModes = []list.Item{
@@ -72,3 +138,12 @@ var Definition = game.Definition{
72138 Help: HelpContent,73139 Import: func(data []byte) (game.Gamer, error) { return ImportModel(data) },74140 }141+142+func (m Mode) generatePuzzle() (Puzzle, error) {143+ rng := rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))144+ return m.generatePuzzleSeeded(rng)145+}146+147+func (m Mode) generatePuzzleSeeded(rng *rand.Rand) (Puzzle, error) {148+ return generatePuzzleSeededWithProfile(m.Size, m.Size, m.MaxCage, m.GivenRatio, m.profile.withDefaults(m.MaxCage), rng)149+}
M rippleeffect/README.md +5 -5
M rippleeffect/generator.go +136 -14
@@ -13,15 +13,35 @@ type Puzzle struct {
1313 Solution grid1414 }1515 16+type shapeBias int17+18+const (19+ shapeBiasNeutral shapeBias = iota20+ shapeBiasCompact21+ shapeBiasWinding22+)23+24+type generationProfile struct {25+ cageWeights []int26+ frontierSamples int27+ shapeBias shapeBias28+ minGivensByCage []int29+}30+1631 func GeneratePuzzle(width, height, maxCage int, givenRatio float64) (Puzzle, error) {1732 rng := rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))1833 return GeneratePuzzleSeeded(width, height, maxCage, givenRatio, rng)1934 }2035 2136 func GeneratePuzzleSeeded(width, height, maxCage int, givenRatio float64, rng *rand.Rand) (Puzzle, error) {37+ return generatePuzzleSeededWithProfile(width, height, maxCage, givenRatio, defaultGenerationProfile(maxCage), rng)38+}39+40+func generatePuzzleSeededWithProfile(width, height, maxCage int, givenRatio float64, profile generationProfile, rng *rand.Rand) (Puzzle, error) {2241 const maxAttempts = 2442+ profile = profile.withDefaults(maxCage)2343 for attempt := 0; attempt < maxAttempts; attempt++ {24- cages := generateCages(width, height, maxCage, rng)44+ cages := generateCages(width, height, maxCage, profile, rng)2545 geo, err := buildGeometry(width, height, cages)2646 if err != nil {2747 continue
@@ -32,7 +52,7 @@ func GeneratePuzzleSeeded(width, height, maxCage int, givenRatio float64, rng *r
3252 continue3353 }3454 35- givens := removeClues(geo, solution, givenRatio, rng)55+ givens := removeClues(geo, solution, givenRatio, profile, rng)3656 if countSolutions(geo, givens, 2) != 1 {3757 continue3858 }
@@ -49,7 +69,50 @@ func GeneratePuzzleSeeded(width, height, maxCage int, givenRatio float64, rng *r
4969 return Puzzle{}, fmt.Errorf("generate ripple effect puzzle %dx%d: exceeded retry budget", width, height)5070 }5171 52-func generateCages(width, height, maxCage int, rng *rand.Rand) []Cage {72+func defaultGenerationProfile(maxCage int) generationProfile {73+ return generationProfile{74+ cageWeights: defaultCageWeights(maxCage),75+ frontierSamples: 1,76+ shapeBias: shapeBiasNeutral,77+ minGivensByCage: make([]int, maxCage+1),78+ }79+}80+81+func defaultCageWeights(maxCage int) []int {82+ base := []int{0, 2, 4, 5, 4, 3, 2, 1, 1, 1}83+ weights := make([]int, maxCage+1)84+ for size := range len(weights) {85+ if size == 0 {86+ continue87+ }88+ weights[size] = base[min(size, len(base)-1)]89+ }90+ return weights91+}92+93+func (p generationProfile) withDefaults(maxCage int) generationProfile {94+ if len(p.cageWeights) == 0 {95+ p.cageWeights = defaultCageWeights(maxCage)96+ }97+ if p.frontierSamples <= 0 {98+ p.frontierSamples = 199+ }100+ if len(p.minGivensByCage) == 0 {101+ p.minGivensByCage = make([]int, maxCage+1)102+ }103+ if len(p.minGivensByCage) <= maxCage {104+ p.minGivensByCage = growIntSlice(p.minGivensByCage, maxCage+1)105+ }106+ return p107+}108+109+func growIntSlice(values []int, size int) []int {110+ grown := make([]int, size)111+ copy(grown, values)112+ return grown113+}114+115+func generateCages(width, height, maxCage int, profile generationProfile, rng *rand.Rand) []Cage {53116 assigned := make([][]bool, height)54117 for y := range height {55118 assigned[y] = make([]bool, width)
@@ -62,8 +125,8 @@ func generateCages(width, height, maxCage int, rng *rand.Rand) []Cage {
62125 return cages63126 }64127 65- target := chooseCageSize(maxCage, remainingUnassigned(assigned), rng)66- shape := growCageShape(start, target, assigned, rng)128+ target := chooseCageSize(maxCage, remainingUnassigned(assigned), profile.cageWeights, rng)129+ shape := growCageShape(start, target, assigned, profile, rng)67130 for _, cell := range shape {68131 assigned[cell.y][cell.x] = true69132 }
@@ -103,11 +166,10 @@ func remainingUnassigned(assigned [][]bool) int {
103166 return count104167 }105168 106-func chooseCageSize(maxCage, remaining int, rng *rand.Rand) int {169+func chooseCageSize(maxCage, remaining int, weights []int, rng *rand.Rand) int {107170 if remaining < maxCage {108171 maxCage = remaining109172 }110- weights := []int{0, 2, 4, 5, 4, 3, 2, 1, 1, 1}111173 totalWeight := 0112174 for size := 1; size <= maxCage; size++ {113175 totalWeight += weights[min(size, len(weights)-1)]
@@ -126,7 +188,7 @@ func chooseCageSize(maxCage, remaining int, rng *rand.Rand) int {
126188 return 1127189 }128190 129-func growCageShape(start point, target int, assigned [][]bool, rng *rand.Rand) []point {191+func growCageShape(start point, target int, assigned [][]bool, profile generationProfile, rng *rand.Rand) []point {130192 shape := []point{start}131193 inShape := map[point]struct{}{start: {}}132194 height := len(assigned)
@@ -153,7 +215,7 @@ func growCageShape(start point, target int, assigned [][]bool, rng *rand.Rand) [
153215 if len(frontier) == 0 {154216 break155217 }156- next := frontier[rng.IntN(len(frontier))]218+ next := chooseFrontierPoint(frontier, inShape, assigned, profile, rng)157219 shape = append(shape, next)158220 inShape[next] = struct{}{}159221 }
@@ -161,6 +223,51 @@ func growCageShape(start point, target int, assigned [][]bool, rng *rand.Rand) [
161223 return shape162224 }163225 226+func chooseFrontierPoint(frontier []point, inShape map[point]struct{}, assigned [][]bool, profile generationProfile, rng *rand.Rand) point {227+ if len(frontier) == 1 || profile.shapeBias == shapeBiasNeutral {228+ return frontier[rng.IntN(len(frontier))]229+ }230+231+ sampleCount := min(profile.frontierSamples, len(frontier))232+ best := frontier[rng.IntN(len(frontier))]233+ bestScore := frontierScore(best, inShape, assigned)234+235+ for pick := 1; pick < sampleCount; pick++ {236+ candidate := frontier[rng.IntN(len(frontier))]237+ score := frontierScore(candidate, inShape, assigned)238+ if prefersFrontierScore(profile.shapeBias, score, bestScore) {239+ best = candidate240+ bestScore = score241+ }242+ }243+244+ return best245+}246+247+func frontierScore(candidate point, inShape map[point]struct{}, assigned [][]bool) int {248+ score := 0249+ for _, neighbor := range orthogonalNeighbors(candidate, len(assigned[0]), len(assigned)) {250+ if assigned[neighbor.y][neighbor.x] {251+ continue252+ }253+ if _, ok := inShape[neighbor]; ok {254+ score++255+ }256+ }257+ return score258+}259+260+func prefersFrontierScore(bias shapeBias, candidate, current int) bool {261+ switch bias {262+ case shapeBiasCompact:263+ return candidate > current264+ case shapeBiasWinding:265+ return candidate < current266+ default:267+ return false268+ }269+}270+164271 func generateSolution(geo *geometry, rng *rand.Rand) (grid, bool) {165272 state := newGrid(geo.width, geo.height)166273 if fillSolution(geo, state, rng) {
@@ -229,17 +336,22 @@ func chooseNextFillCell(geo *geometry, state grid, rng *rand.Rand) (point, []int
229336 return best, bestCandidates, true230337 }231338 232-func removeClues(geo *geometry, solution grid, givenRatio float64, rng *rand.Rand) grid {339+func removeClues(geo *geometry, solution grid, givenRatio float64, profile generationProfile, rng *rand.Rand) grid {233340 givens := cloneGrid(solution)234341 totalCells := geo.width * geo.height235342 target := int(float64(totalCells) * givenRatio)236- if target < len(geo.cages) {237- target = len(geo.cages)238- }239343 240344 remainingByCage := make([]int, len(geo.cages))345+ minimumByCage := make([]int, len(geo.cages))346+ minimumTotal := 0241347 for cageIdx, cells := range geo.cageCells {242348 remainingByCage[cageIdx] = len(cells)349+ minimumByCage[cageIdx] = minGivensForCage(profile, len(cells))350+ minimumTotal += minimumByCage[cageIdx]351+ }352+353+ if target < minimumTotal {354+ target = minimumTotal243355 }244356 245357 cells := make([]point, 0, totalCells)
@@ -259,7 +371,7 @@ func removeClues(geo *geometry, solution grid, givenRatio float64, rng *rand.Ran
259371 }260372 261373 cageIdx := geo.cageGrid[cell.y][cell.x]262- if remainingByCage[cageIdx] <= 1 {374+ if remainingByCage[cageIdx] <= minimumByCage[cageIdx] {263375 continue264376 }265377
@@ -276,3 +388,13 @@ func removeClues(geo *geometry, solution grid, givenRatio float64, rng *rand.Ran
276388 277389 return givens278390 }391+392+func minGivensForCage(profile generationProfile, cageSize int) int {393+ if cageSize <= 0 {394+ return 0395+ }396+ if cageSize >= len(profile.minGivensByCage) {397+ return max(1, profile.minGivensByCage[len(profile.minGivensByCage)-1])398+ }399+ return max(1, min(profile.minGivensByCage[cageSize], cageSize))400+}
M rippleeffect/help.md +1 -1
M rippleeffect/rippleeffect_test.go +96 -0
@@ -1,6 +1,7 @@
11 package rippleeffect22 33 import (4+ "fmt"45 "image"56 "image/color"67 "math/rand/v2"
@@ -185,6 +186,67 @@ func TestGeneratePuzzleSeeded(t *testing.T) {
185186 }186187 }187188 189+func TestSampledGivenValueDistributionMatchesSolutions(t *testing.T) {190+ if testing.Short() {191+ t.Skip("skipping sampled ripple given-value distribution test in short mode")192+ }193+194+ sampleCounts := []int{8, 6, 4, 2, 1}195+ shareDriftTolerances := []float64{0.12, 0.10, 0.10, 0.12, 0.16}196+197+ for i, item := range Modes {198+ mode := item.(Mode)199+ samples := sampleCounts[i]200+ tolerance := shareDriftTolerances[i]201+202+ t.Run(fmt.Sprintf("%s-%d", mode.Title(), samples), func(t *testing.T) {203+ solutionCounts := make([]int, mode.MaxCage+1)204+ givenCounts := make([]int, mode.MaxCage+1)205+ totalSolutions := 0206+ totalGivens := 0207+208+ for sample := 0; sample < samples; sample++ {209+ rng := rand.New(rand.NewPCG(uint64(3000+i*37+sample), uint64(4000+i*41+sample)))210+ puzzle, err := mode.generatePuzzleSeeded(rng)211+ if err != nil {212+ t.Fatalf("generatePuzzleSeeded failed: %v", err)213+ }214+215+ totalSolutions += accumulateValueCounts(solutionCounts, puzzle.Solution)216+ totalGivens += accumulateValueCounts(givenCounts, puzzle.Givens)217+ }218+219+ if totalSolutions == 0 {220+ t.Fatal("expected sampled solutions to contain values")221+ }222+ if totalGivens == 0 {223+ t.Fatal("expected sampled puzzles to contain givens")224+ }225+226+ t.Logf("sampled solution values: %s", formatValueDistribution(solutionCounts))227+ t.Logf("sampled given values: %s", formatValueDistribution(givenCounts))228+229+ for value := 1; value < len(solutionCounts); value++ {230+ if solutionCounts[value] == 0 {231+ continue232+ }233+234+ solutionShare := float64(solutionCounts[value]) / float64(totalSolutions)235+ givenShare := float64(givenCounts[value]) / float64(totalGivens)236+ if diff := givenShare - solutionShare; diff < -tolerance || diff > tolerance {237+ t.Fatalf(238+ "value %d share %.2f outside %.2f +/- %.2f",239+ value,240+ givenShare,241+ solutionShare,242+ tolerance,243+ )244+ }245+ }246+ })247+ }248+}249+188250 func TestSaveRoundTrip(t *testing.T) {189251 puzzle := Puzzle{190252 Width: 3,
@@ -565,3 +627,37 @@ func TestImportRejectsInvalidGivens(t *testing.T) {
565627 func pointCursor(x, y int) game.Cursor {566628 return game.Cursor{X: x, Y: y}567629 }630+631+func accumulateValueCounts(counts []int, state grid) int {632+ total := 0633+ for y := range len(state) {634+ for x := range len(state[y]) {635+ value := state[y][x]636+ if value <= 0 {637+ continue638+ }639+ counts[value]++640+ total++641+ }642+ }643+ return total644+}645+646+func formatValueDistribution(counts []int) string {647+ total := 0648+ for value := 1; value < len(counts); value++ {649+ total += counts[value]650+ }651+ if total == 0 {652+ return "none"653+ }654+655+ parts := make([]string, 0, len(counts)-1)656+ for value := 1; value < len(counts); value++ {657+ if counts[value] == 0 {658+ continue659+ }660+ parts = append(parts, fmt.Sprintf("%d=%d(%.1f%%)", value, counts[value], 100*float64(counts[value])/float64(total)))661+ }662+ return strings.Join(parts, ", ")663+}
M rippleeffect/style.go +1 -1
M shikaku/Model.go +6 -7
M shikaku/README.md +4 -5
M shikaku/help.md +3 -4
M shikaku/keys.go +3 -8
M shikaku/shikaku_test.go +8 -8
@@ -1046,9 +1046,9 @@ func TestRectFromCorners(t *testing.T) {
10461046 }10471047 }10481048 1049-// --- Escape cancel behavior (P1) ---1049+// --- Backspace cancel behavior (P1) ---10501050 1051-func TestEscapeCancelsPendingExpansion(t *testing.T) {1051+func TestBackspaceCancelsPendingExpansion(t *testing.T) {10521052 p := simplePuzzle()10531053 selected := 010541054 m := Model{
@@ -1061,15 +1061,15 @@ func TestEscapeCancelsPendingExpansion(t *testing.T) {
10611061 keys: DefaultKeyMap,10621062 }10631063 1064- next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})1064+ next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyBackspace})10651065 got := next.(Model)10661066 10671067 if got.selectedClue != nil {1068- t.Fatal("expected selected clue to be cleared on escape")1068+ t.Fatal("expected selected clue to be cleared on backspace")10691069 }10701070 }10711071 1072-func TestEscapeCancelsPendingMousePreviewInNavMode(t *testing.T) {1072+func TestBackspaceCancelsPendingMousePreviewInNavMode(t *testing.T) {10731073 p := simplePuzzle()10741074 anchor := [2]int{0, 0}10751075 preview := Rectangle{X: 0, Y: 0, W: 2, H: 2}
@@ -1080,14 +1080,14 @@ func TestEscapeCancelsPendingMousePreviewInNavMode(t *testing.T) {
10801080 mousePreview: &preview,10811081 }10821082 1083- next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})1083+ next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyBackspace})10841084 got := next.(Model)10851085 10861086 if got.mouseDragAnchor != nil {1087- t.Fatal("expected mouse drag anchor to be cleared on escape")1087+ t.Fatal("expected mouse drag anchor to be cleared on backspace")10881088 }10891089 if got.mousePreview != nil {1090- t.Fatal("expected mouse preview to be cleared on escape")1090+ t.Fatal("expected mouse preview to be cleared on backspace")10911091 }10921092 }10931093
M shikaku/style.go +4 -4
M stats/stats.go +5 -2
M stats/stats_test.go +7 -1
M sudoku/README.md +1 -1
M sudoku/help.md +1 -1
M sudoku/style.go +1 -1
M takuzu/README.md +1 -1
M takuzu/help.md +1 -1
M takuzu/style.go +1 -1
M takuzuplus/help.md +1 -1
M takuzuplus/style.go +1 -1
M wordsearch/README.md +3 -3
M wordsearch/help.md +3 -3
M wordsearch/keys.go +2 -2
M wordsearch/style.go +2 -2
M wordsearch/wordsearch_test.go +18 -0