feat: add Shikaku puzzle game mode
- commit
- 23762f6a7fd18efdf91b55bb0c8a3c367c103fe1
- parent
- 329f53eed26b08b141cc66f386f1e7fd00b010c7
- parent
- be4f5d9613a284dfd06c4eb2cdf28f0d23e9829e
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | daily/ | 2 | + |
| M | justfile | 1 | + |
| M | model.go | 10 | +- |
| M | resolve/ | 2 | + |
| A | shikaku/ | 52 | +++ |
| A | shikaku/ | 75 | ++++ |
| A | shikaku/ | 228 | +++++++++++++ |
| A | shikaku/ | 58 | +++ |
| A | shikaku/ | 154 | +++++++++ |
| A | shikaku/ | 51 | +++ |
| A | shikaku/ | 60 | +++ |
| A | shikaku/ | 183 | ++++++++++ |
| A | shikaku/ | 527 | ++++++++++++++++++++++++++++++ |
| A | shikaku/ | 193 | +++++++++++ |
| M | update.go | 20 | +- |
| A | vhs/ | 94 | +++++ |
@@ -11,6 +11,7 @@ import (
1111 "github.com/FelineStateMachine/puzzletea/lightsout"1212 "github.com/FelineStateMachine/puzzletea/namegen"1313 "github.com/FelineStateMachine/puzzletea/nonogram"14+ "github.com/FelineStateMachine/puzzletea/shikaku"1415 "github.com/FelineStateMachine/puzzletea/sudoku"1516 "github.com/FelineStateMachine/puzzletea/takuzu"1617 "github.com/FelineStateMachine/puzzletea/wordsearch"@@ -37,6 +38,7 @@ var pool = []struct {
3738 {"Hashiwokakero", hashiwokakero.DailyModes},3839 {"Hitori", hitori.DailyModes},3940 {"Lights Out", lightsout.DailyModes},41+ {"Shikaku", shikaku.DailyModes},4042 {"Word Search", wordsearch.DailyModes},4143 }4244 @@ -8,6 +8,7 @@ import (
88 "github.com/FelineStateMachine/puzzletea/hitori"99 "github.com/FelineStateMachine/puzzletea/lightsout"1010 "github.com/FelineStateMachine/puzzletea/nonogram"11+ "github.com/FelineStateMachine/puzzletea/shikaku"1112 "github.com/FelineStateMachine/puzzletea/store"1213 "github.com/FelineStateMachine/puzzletea/sudoku"1314 "github.com/FelineStateMachine/puzzletea/takuzu"@@ -29,6 +30,7 @@ var (
2930 game.Category{Name: "Hitori", Desc: "Shade cells to eliminate duplicates.", Modes: hitori.Modes, Help: hitori.HelpContent},3031 game.Category{Name: "Lights Out", Desc: "Turn off all the lights.", Modes: lightsout.Modes, Help: lightsout.HelpContent},3132 game.Category{Name: "Nonogram", Desc: "Fill cells to match row and column hints.", Modes: nonogram.Modes, Help: nonogram.HelpContent},33+ game.Category{Name: "Shikaku", Desc: "Divide the grid into rectangles.", Modes: shikaku.Modes, Help: shikaku.HelpContent},3234 game.Category{Name: "Sudoku", Desc: "Fill the 9x9 grid following sudoku rules.", Modes: sudoku.Modes, Help: sudoku.HelpContent},3335 game.Category{Name: "Takuzu", Desc: "Fill the grid with ● and ○.", Modes: takuzu.Modes, Help: takuzu.HelpContent},3436 game.Category{Name: "Word Search", Desc: "Find hidden words in a letter grid.", Modes: wordsearch.Modes, Help: wordsearch.HelpContent},@@ -90,9 +92,11 @@ type model struct {
9092 dailyModeTitle string // e.g. "Standard"9193 9294 // Help page state93- helpSelectList list.Model94- helpCategory game.Category95- helpViewport viewport.Model95+ helpSelectList list.Model96+ helpCategory game.Category97+ helpViewport viewport.Model98+ helpRenderer *glamour.TermRenderer99+ helpRendererWidth int96100 }97101 98102 func initialModel(s *store.Store) model {@@ -29,6 +29,8 @@ var CategoryAliases = map[string]string{
2929 "takuzu": "takuzu",3030 "binairo": "takuzu",3131 "binary": "takuzu",32+ "shikaku": "shikaku",33+ "rectangles": "shikaku",3234 "words": "word search",3335 "wordsearch": "word search",3436 "ws": "word search",@@ -0,0 +1,52 @@
1+package shikaku2+3+import (4+ "encoding/json"5+ "fmt"6+7+ "github.com/FelineStateMachine/puzzletea/game"8+)9+10+// Save represents the serialized state of a Shikaku game.11+type Save struct {12+ Width int `json:"width"`13+ Height int `json:"height"`14+ Clues []Clue `json:"clues"`15+ Rectangles []Rectangle `json:"rectangles"`16+ ModeTitle string `json:"mode_title"`17+}18+19+func (m Model) GetSave() ([]byte, error) {20+ save := Save{21+ Width: m.puzzle.Width,22+ Height: m.puzzle.Height,23+ Clues: m.puzzle.Clues,24+ Rectangles: m.puzzle.Rectangles,25+ ModeTitle: m.modeTitle,26+ }27+ data, err := json.Marshal(save)28+ if err != nil {29+ return nil, fmt.Errorf("failed to marshal shikaku save: %w", err)30+ }31+ return data, nil32+}33+34+// ImportModel reconstructs a Model from saved JSON data.35+func ImportModel(data []byte) (*Model, error) {36+ var s Save37+ if err := json.Unmarshal(data, &s); err != nil {38+ return nil, fmt.Errorf("failed to unmarshal shikaku save: %w", err)39+ }40+41+ return &Model{42+ puzzle: Puzzle{43+ Width: s.Width,44+ Height: s.Height,45+ Clues: s.Clues,46+ Rectangles: s.Rectangles,47+ },48+ cursor: game.Cursor{X: 0, Y: 0},49+ keys: DefaultKeyMap,50+ modeTitle: s.ModeTitle,51+ }, nil52+}@@ -0,0 +1,75 @@
1+package shikaku2+3+import (4+ _ "embed"5+ "math/rand/v2"6+7+ "github.com/FelineStateMachine/puzzletea/game"8+ "github.com/charmbracelet/bubbles/list"9+)10+11+//go:embed help.md12+var HelpContent string13+14+func init() {15+ game.Register("Shikaku", func(data []byte) (game.Gamer, error) {16+ return ImportModel(data)17+ })18+}19+20+// ShikakuMode defines a Shikaku difficulty/configuration.21+type ShikakuMode struct {22+ game.BaseMode23+ Width int24+ Height int25+ MaxRectSize int26+}27+28+var (29+ _ game.Mode = ShikakuMode{}30+ _ game.Spawner = ShikakuMode{}31+ _ game.SeededSpawner = ShikakuMode{}32+)33+34+// NewMode creates a new Shikaku game mode.35+func NewMode(title, description string, width, height, maxRectSize int) ShikakuMode {36+ return ShikakuMode{37+ BaseMode: game.NewBaseMode(title, description),38+ Width: width,39+ Height: height,40+ MaxRectSize: maxRectSize,41+ }42+}43+44+// Spawn creates a new game instance for this mode.45+func (s ShikakuMode) Spawn() (game.Gamer, error) {46+ puzzle, err := GeneratePuzzle(s.Width, s.Height, s.MaxRectSize)47+ if err != nil {48+ return nil, err49+ }50+ return New(s, puzzle), nil51+}52+53+// SpawnSeeded creates a new game instance using a deterministic RNG.54+func (s ShikakuMode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) {55+ puzzle, err := GeneratePuzzleSeeded(s.Width, s.Height, s.MaxRectSize, rng)56+ if err != nil {57+ return nil, err58+ }59+ return New(s, puzzle), nil60+}61+62+// Modes defines the available difficulty levels.63+var Modes = []list.Item{64+ NewMode("Mini 5x5", "5x5 grid, gentle introduction.", 5, 5, 5),65+ NewMode("Easy 7x7", "7x7 grid, straightforward puzzles.", 7, 7, 8),66+ NewMode("Medium 8x8", "8x8 grid, moderate challenge.", 8, 8, 12),67+ NewMode("Hard 10x10", "10x10 grid, requires careful planning.", 10, 10, 15),68+ NewMode("Expert 12x12", "12x12 grid, maximum challenge.", 12, 12, 20),69+}70+71+// DailyModes is the subset of Modes eligible for daily puzzle rotation.72+var DailyModes = []list.Item{73+ Modes[1], // Easy 7x774+ Modes[2], // Medium 8x875+}@@ -0,0 +1,228 @@
1+package shikaku2+3+import (4+ "fmt"5+6+ "github.com/FelineStateMachine/puzzletea/game"7+8+ "github.com/charmbracelet/bubbles/key"9+ tea "github.com/charmbracelet/bubbletea"10+ "github.com/charmbracelet/lipgloss"11+)12+13+// expansion tracks how far the preview rectangle extends from the clue cell.14+type expansion struct {15+ clueID int16+ left, right, up, down int17+}18+19+// rect converts the expansion to a Rectangle.20+func (e expansion) rect(clue *Clue) Rectangle {21+ return Rectangle{22+ ClueID: e.clueID,23+ X: clue.X - e.left,24+ Y: clue.Y - e.up,25+ W: e.left + 1 + e.right,26+ H: e.up + 1 + e.down,27+ }28+}29+30+var _ game.Gamer = Model{}31+32+// Model implements game.Gamer for Shikaku.33+type Model struct {34+ puzzle Puzzle35+ cursor game.Cursor36+ selectedClue *int // nil = navigation mode, non-nil = expansion mode37+ expansion expansion38+ keys KeyMap39+ modeTitle string40+ showFullHelp bool41+}42+43+// New creates a new Shikaku game model.44+func New(mode ShikakuMode, puzzle Puzzle) game.Gamer {45+ return Model{46+ puzzle: puzzle,47+ cursor: game.Cursor{X: 0, Y: 0},48+ keys: DefaultKeyMap,49+ modeTitle: mode.Title(),50+ }51+}52+53+func (m Model) Init() tea.Cmd { return nil }54+55+func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {56+ switch msg := msg.(type) {57+ case game.HelpToggleMsg:58+ m.showFullHelp = msg.Show59+ case game.ResetMsg:60+ m.puzzle.Rectangles = nil61+ m.cursor = game.Cursor{}62+ m.selectedClue = nil63+ case tea.KeyMsg:64+ if m.puzzle.IsSolved() {65+ m.cursor.Move(m.keys.CursorKeyMap, msg, m.puzzle.Width-1, m.puzzle.Height-1)66+ } else if m.selectedClue != nil {67+ m = m.handleExpansionMode(msg)68+ } else {69+ m = m.handleNavMode(msg)70+ }71+ }72+ return m, nil73+}74+75+func (m Model) handleNavMode(msg tea.KeyMsg) Model {76+ switch {77+ case key.Matches(msg, m.keys.Select):78+ clue := m.puzzle.FindClueAt(m.cursor.X, m.cursor.Y)79+ if clue != nil {80+ m.selectedClue = &clue.ID81+ m.expansion = expansion{clueID: clue.ID}82+ // Initialize from existing rectangle if present.83+ if r := m.puzzle.FindRectangleForClue(clue.ID); r != nil {84+ m.expansion.left = clue.X - r.X85+ m.expansion.right = r.X + r.W - 1 - clue.X86+ m.expansion.up = clue.Y - r.Y87+ m.expansion.down = r.Y + r.H - 1 - clue.Y88+ }89+ }90+ case key.Matches(msg, m.keys.Delete):91+ // Delete rectangle at cursor position.92+ owner := m.puzzle.CellOwner(m.cursor.X, m.cursor.Y)93+ if owner >= 0 {94+ m.puzzle.RemoveRectangle(owner)95+ }96+ default:97+ m.cursor.Move(m.keys.CursorKeyMap, msg, m.puzzle.Width-1, m.puzzle.Height-1)98+ }99+ return m100+}101+102+func (m Model) handleExpansionMode(msg tea.KeyMsg) Model {103+ clue := m.puzzle.FindClueByID(*m.selectedClue)104+ if clue == nil {105+ m.selectedClue = nil106+ return m107+ }108+109+ switch {110+ // Expand each side independently.111+ case key.Matches(msg, m.keys.Right):112+ if m.expansion.right < m.puzzle.Width-1-clue.X {113+ m.expansion.right++114+ }115+ case key.Matches(msg, m.keys.Left):116+ if m.expansion.left < clue.X {117+ m.expansion.left++118+ }119+ case key.Matches(msg, m.keys.Down):120+ if m.expansion.down < m.puzzle.Height-1-clue.Y {121+ m.expansion.down++122+ }123+ case key.Matches(msg, m.keys.Up):124+ if m.expansion.up < clue.Y {125+ m.expansion.up++126+ }127+ // Shrink each side independently.128+ case key.Matches(msg, m.keys.ShrinkRight):129+ if m.expansion.right > 0 {130+ m.expansion.right--131+ }132+ case key.Matches(msg, m.keys.ShrinkLeft):133+ if m.expansion.left > 0 {134+ m.expansion.left--135+ }136+ case key.Matches(msg, m.keys.ShrinkDown):137+ if m.expansion.down > 0 {138+ m.expansion.down--139+ }140+ case key.Matches(msg, m.keys.ShrinkUp):141+ if m.expansion.up > 0 {142+ m.expansion.up--143+ }144+ case key.Matches(msg, m.keys.Select):145+ rect := m.expansion.rect(clue)146+ if rect.Area() == clue.Value && !m.puzzle.Overlaps(rect, clue.ID) {147+ m.puzzle.SetRectangle(rect)148+ m.selectedClue = nil149+ }150+ case key.Matches(msg, m.keys.Cancel):151+ m.selectedClue = nil152+ case key.Matches(msg, m.keys.Delete):153+ m.puzzle.RemoveRectangle(clue.ID)154+ m.selectedClue = nil155+ }156+ return m157+}158+159+func (m Model) View() string {160+ solved := m.puzzle.IsSolved()161+ title := game.TitleBarView("Shikaku", m.modeTitle, solved)162+ grid := gridView(m, solved)163+ info := infoView(&m.puzzle)164+ status := statusBarView(m.selectedClue != nil, m.showFullHelp)165+ return lipgloss.JoinVertical(lipgloss.Center, title, grid, info, status)166+}167+168+func (m Model) SetTitle(t string) game.Gamer {169+ m.modeTitle = t170+ return m171+}172+173+func (m Model) IsSolved() bool {174+ return m.puzzle.IsSolved()175+}176+177+func (m Model) Reset() game.Gamer {178+ m.puzzle.Rectangles = nil179+ m.cursor = game.Cursor{}180+ m.selectedClue = nil181+ return m182+}183+184+func (m Model) GetDebugInfo() string {185+ status := "In Progress"186+ if m.puzzle.IsSolved() {187+ status = "Solved"188+ }189+190+ selectedStr := "None"191+ if m.selectedClue != nil {192+ selectedStr = fmt.Sprintf("%d", *m.selectedClue)193+ }194+195+ s := game.DebugHeader("Shikaku", [][2]string{196+ {"Status", status},197+ {"Grid Size", fmt.Sprintf("%dx%d", m.puzzle.Width, m.puzzle.Height)},198+ {"Clues", fmt.Sprintf("%d", len(m.puzzle.Clues))},199+ {"Rectangles", fmt.Sprintf("%d", len(m.puzzle.Rectangles))},200+ {"Cursor", fmt.Sprintf("(%d, %d)", m.cursor.X, m.cursor.Y)},201+ {"Selected Clue", selectedStr},202+ })203+204+ var rows [][]string205+ for _, c := range m.puzzle.Clues {206+ r := m.puzzle.FindRectangleForClue(c.ID)207+ rectStr := "-"208+ rectStatus := "Empty"209+ if r != nil {210+ rectStr = fmt.Sprintf("(%d,%d) %dx%d", r.X, r.Y, r.W, r.H)211+ if r.Area() == c.Value {212+ rectStatus = "Correct"213+ } else {214+ rectStatus = fmt.Sprintf("Area %d", r.Area())215+ }216+ }217+ rows = append(rows, []string{218+ fmt.Sprintf("%d", c.ID),219+ fmt.Sprintf("(%d,%d)", c.X, c.Y),220+ fmt.Sprintf("%d", c.Value),221+ rectStr,222+ rectStatus,223+ })224+ }225+ s += game.DebugTable("Clues", []string{"ID", "Pos", "Value", "Rectangle", "Status"}, rows)226+227+ return s228+}@@ -0,0 +1,58 @@
1+# Shikaku2+3+Divide the grid into rectangles so each contains exactly one number equal to its area.4+5+6+7+## How to Play8+9+The grid contains numbered clues. Your goal is to partition the entire grid10+into non-overlapping rectangles where each rectangle contains exactly one11+clue and the clue's value equals the rectangle's area.12+13+1. Navigate to a numbered clue and press `Enter` or `Space` to select it.14+2. Press arrow keys to expand the rectangle in each direction.15+3. Press `Shift+Arrow` to shrink a side if you overshot.16+4. Press `Enter` or `Space` to confirm when the preview is green.17+5. Press `Escape` to cancel, or `Backspace` to delete.18+19+## Controls20+21+### Navigation Mode22+23+| Key | Action |24+|-----|--------|25+| Arrow keys / WASD / hjkl | Move cursor |26+| `Enter` / `Space` | Select clue for expansion |27+| `Backspace` | Delete rectangle at cursor |28+| `Ctrl+H` | Toggle full help |29+| `Ctrl+E` | Toggle debug overlay |30+| `Ctrl+N` | Return to main menu |31+32+### Expansion Mode (after selecting a clue)33+34+| Key | Action |35+|-----|--------|36+| Arrow keys / WASD / hjkl | Expand rectangle in that direction |37+| Shift+Arrow / WASD / HJKL | Shrink rectangle from that side |38+| `Enter` / `Space` | Confirm placement (green preview only) |39+| `Escape` | Cancel, discard preview |40+| `Backspace` | Delete existing rectangle, return to nav |41+42+## Modes43+44+| Mode | Grid | Max Rect | Description |45+|------|------|----------|-------------|46+| Mini 5x5 | 5x5 | 5 | Gentle introduction |47+| Easy 7x7 | 7x7 | 8 | Straightforward puzzles |48+| Medium 8x8 | 8x8 | 12 | Moderate challenge |49+| Hard 10x10 | 10x10 | 15 | Requires careful planning |50+| Expert 12x12 | 12x12 | 20 | Maximum challenge |51+52+## Quick Start53+54+```bash55+puzzletea new shikaku mini-5x556+puzzletea new shikaku easy-7x757+puzzletea new shikaku hard-10x1058+```@@ -0,0 +1,154 @@
1+package shikaku2+3+import (4+ "errors"5+ "math/rand/v2"6+)7+8+const maxGenerateAttempts = 1009+10+// genRect is used during generation to track grid partitions.11+type genRect struct {12+ x, y, w, h int13+}14+15+// GeneratePuzzle creates a Shikaku puzzle with random RNG.16+func GeneratePuzzle(width, height, maxRectSize int) (Puzzle, error) {17+ rng := rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))18+ return GeneratePuzzleSeeded(width, height, maxRectSize, rng)19+}20+21+// GeneratePuzzleSeeded creates a Shikaku puzzle with a deterministic RNG.22+func GeneratePuzzleSeeded(width, height, maxRectSize int, rng *rand.Rand) (Puzzle, error) {23+ for range maxGenerateAttempts {24+ p := tryGenerate(width, height, maxRectSize, rng)25+ if p != nil {26+ return *p, nil27+ }28+ }29+ return Puzzle{}, errors.New("failed to generate shikaku puzzle after maximum attempts")30+}31+32+func tryGenerate(width, height, maxRectSize int, rng *rand.Rand) *Puzzle {33+ // Grid tracks which partition each cell belongs to (-1 = uncovered).34+ grid := make([][]int, height)35+ for y := range height {36+ grid[y] = make([]int, width)37+ for x := range width {38+ grid[y][x] = -139+ }40+ }41+42+ var partitions []genRect43+44+ if !partitionGrid(grid, width, height, maxRectSize, &partitions, rng) {45+ return nil46+ }47+48+ // Place clues: pick a random cell within each partition.49+ clues := make([]Clue, len(partitions))50+ for i, r := range partitions {51+ cx := r.x + rng.IntN(r.w)52+ cy := r.y + rng.IntN(r.h)53+ clues[i] = Clue{54+ ID: i,55+ X: cx,56+ Y: cy,57+ Value: r.w * r.h,58+ }59+ }60+61+ return &Puzzle{62+ Width: width,63+ Height: height,64+ Clues: clues,65+ }66+}67+68+// partitionGrid recursively fills the grid with non-overlapping rectangles.69+func partitionGrid(grid [][]int, width, height, maxRectSize int, partitions *[]genRect, rng *rand.Rand) bool {70+ // Find first uncovered cell (top-left scan).71+ fx, fy := -1, -172+ for y := range height {73+ for x := range width {74+ if grid[y][x] == -1 {75+ fx, fy = x, y76+ break77+ }78+ }79+ if fx >= 0 {80+ break81+ }82+ }83+84+ if fx < 0 {85+ return true // all cells covered86+ }87+88+ type dim struct{ w, h int }89+ var candidates []dim90+91+ // Enumerate all rectangle sizes that fit at (fx, fy).92+ for w := 1; w <= width-fx; w++ {93+ for h := 1; h <= height-fy; h++ {94+ if w*h > maxRectSize {95+ continue96+ }97+ if fits(grid, fx, fy, w, h) {98+ candidates = append(candidates, dim{w, h})99+ }100+ }101+ }102+103+ // Shuffle for randomness.104+ rng.Shuffle(len(candidates), func(i, j int) {105+ candidates[i], candidates[j] = candidates[j], candidates[i]106+ })107+108+ id := len(*partitions)109+ for _, c := range candidates {110+ // Place rectangle.111+ place(grid, fx, fy, c.w, c.h, id)112+ *partitions = append(*partitions, genRect{fx, fy, c.w, c.h})113+114+ if partitionGrid(grid, width, height, maxRectSize, partitions, rng) {115+ return true116+ }117+118+ // Backtrack.119+ *partitions = (*partitions)[:len(*partitions)-1]120+ clearGrid(grid, fx, fy, c.w, c.h)121+ }122+123+ return false124+}125+126+// fits checks if a rectangle of size w*h can be placed at (x, y) without overlap.127+func fits(grid [][]int, x, y, w, h int) bool {128+ for dy := range h {129+ for dx := range w {130+ if grid[y+dy][x+dx] != -1 {131+ return false132+ }133+ }134+ }135+ return true136+}137+138+// place marks cells in the grid with the given partition ID.139+func place(grid [][]int, x, y, w, h, id int) {140+ for dy := range h {141+ for dx := range w {142+ grid[y+dy][x+dx] = id143+ }144+ }145+}146+147+// clearGrid resets cells in the grid back to uncovered.148+func clearGrid(grid [][]int, x, y, w, h int) {149+ for dy := range h {150+ for dx := range w {151+ grid[y+dy][x+dx] = -1152+ }153+ }154+}@@ -0,0 +1,51 @@
1+# Shikaku2+3+Divide the grid into rectangles so that each rectangle contains exactly one number equal to its area.4+5+## Rules6+7+- The grid must be completely covered by non-overlapping rectangles.8+- Each rectangle must contain **exactly one** numbered clue.9+- The number on the clue must equal the **area** of its rectangle.10+11+The puzzle is solved when all three rules hold simultaneously.12+13+## Controls14+15+### Navigation Mode16+17+| Key | Action |18+|-----|--------|19+| `Arrows` / `wasd` / `hjkl` | Move cursor |20+| `Enter` / `Space` | Select clue to expand |21+| `Backspace` | Delete rectangle at cursor |22+23+### Expansion Mode24+25+After selecting a clue, you enter Expansion Mode. Each side of the26+rectangle is controlled independently.27+28+| Key | Action |29+|-----|--------|30+| `Arrow` / `wasd` / `hjkl` | Expand rectangle in that direction |31+| `Shift+Arrow` / `WASD` / `HJKL` | Shrink rectangle from that side |32+| `Enter` / `Space` | Confirm placement (if valid) |33+| `Escape` | Cancel, discard preview |34+| `Backspace` | Delete existing rectangle, return to nav |35+36+The preview turns **green** when the area matches the clue value and there37+are no overlaps, or **red** otherwise. You can only confirm a green preview.38+39+---40+41+| Key | Action |42+|-----|--------|43+| `Ctrl+R` | Reset puzzle |44+| `Ctrl+H` | Toggle help bar |45+| `Ctrl+N` | Return to main menu |46+47+## Tips48+49+- **Start with 1s and 2s.** Small clues have very few possible rectangles, making them easy to place first.50+- **Use the edges.** Clues near corners and borders have fewer valid rectangle configurations, so they constrain the puzzle early.51+- **Watch for forced placements.** If a clue has only one possible rectangle that avoids overlapping placed rectangles, it must go there.@@ -0,0 +1,60 @@
1+package shikaku2+3+import (4+ "github.com/FelineStateMachine/puzzletea/game"5+ "github.com/charmbracelet/bubbles/key"6+)7+8+// KeyMap defines the keybindings for Shikaku.9+type KeyMap struct {10+ game.CursorKeyMap11+ ShrinkUp key.Binding12+ ShrinkDown key.Binding13+ ShrinkLeft key.Binding14+ ShrinkRight key.Binding15+ Select key.Binding16+ Cancel key.Binding17+ Delete key.Binding18+}19+20+// DefaultKeyMap provides the standard keybindings.21+var DefaultKeyMap = KeyMap{22+ CursorKeyMap: game.DefaultCursorKeyMap,23+ ShrinkUp: key.NewBinding(24+ key.WithKeys("shift+up", "K", "W"),25+ key.WithHelp("shift+↑", "Shrink up"),26+ ),27+ ShrinkDown: key.NewBinding(28+ key.WithKeys("shift+down", "J", "S"),29+ key.WithHelp("shift+↓", "Shrink down"),30+ ),31+ ShrinkLeft: key.NewBinding(32+ key.WithKeys("shift+left", "H", "A"),33+ key.WithHelp("shift+←", "Shrink left"),34+ ),35+ ShrinkRight: key.NewBinding(36+ key.WithKeys("shift+right", "L", "D"),37+ key.WithHelp("shift+→", "Shrink right"),38+ ),39+ Select: key.NewBinding(40+ key.WithKeys("enter", " "),41+ key.WithHelp("enter/space", "Select/Confirm"),42+ ),43+ Cancel: key.NewBinding(44+ key.WithKeys("esc"),45+ key.WithHelp("esc", "Cancel"),46+ ),47+ Delete: key.NewBinding(48+ key.WithKeys("backspace", "delete"),49+ key.WithHelp("bkspc", "Delete"),50+ ),51+}52+53+// GetFullHelp implements game.Gamer.54+func (m Model) GetFullHelp() [][]key.Binding {55+ return [][]key.Binding{56+ {m.keys.Up, m.keys.Down, m.keys.Left, m.keys.Right},57+ {m.keys.ShrinkUp, m.keys.ShrinkDown, m.keys.ShrinkLeft, m.keys.ShrinkRight},58+ {m.keys.Select, m.keys.Cancel, m.keys.Delete},59+ }60+}@@ -0,0 +1,183 @@
1+// Package shikaku implements the Shikaku rectangle-partition puzzle game.2+package shikaku3+4+// Clue represents a numbered cell in the grid.5+type Clue struct {6+ ID int `json:"id"`7+ X int `json:"x"`8+ Y int `json:"y"`9+ Value int `json:"value"` // required rectangle area10+}11+12+// Rectangle represents a player-placed rectangle covering grid cells.13+type Rectangle struct {14+ ClueID int `json:"clue_id"`15+ X int `json:"x"` // top-left16+ Y int `json:"y"`17+ W int `json:"w"` // dimensions18+ H int `json:"h"`19+}20+21+// Area returns the area of the rectangle.22+func (r Rectangle) Area() int { return r.W * r.H }23+24+// Contains reports whether the rectangle covers the cell at (cx, cy).25+func (r Rectangle) Contains(cx, cy int) bool {26+ return cx >= r.X && cx < r.X+r.W && cy >= r.Y && cy < r.Y+r.H27+}28+29+// Puzzle holds the full state of a Shikaku game.30+type Puzzle struct {31+ Width, Height int32+ Clues []Clue33+ Rectangles []Rectangle // player-placed34+ clueIndex map[[2]int]int35+}36+37+// FindClueAt returns the clue at position (x, y), or nil.38+func (p *Puzzle) FindClueAt(x, y int) *Clue {39+ if p.clueIndex == nil {40+ p.clueIndex = make(map[[2]int]int, len(p.Clues))41+ for i, c := range p.Clues {42+ p.clueIndex[[2]int{c.X, c.Y}] = i43+ }44+ }45+ if idx, ok := p.clueIndex[[2]int{x, y}]; ok {46+ return &p.Clues[idx]47+ }48+ return nil49+}50+51+// FindClueByID returns the clue with the given ID, or nil.52+func (p *Puzzle) FindClueByID(id int) *Clue {53+ if id >= 0 && id < len(p.Clues) && p.Clues[id].ID == id {54+ return &p.Clues[id]55+ }56+ for i := range p.Clues {57+ if p.Clues[i].ID == id {58+ return &p.Clues[i]59+ }60+ }61+ return nil62+}63+64+// FindRectangleForClue returns the rectangle placed for the given clue ID, or nil.65+func (p *Puzzle) FindRectangleForClue(clueID int) *Rectangle {66+ for i := range p.Rectangles {67+ if p.Rectangles[i].ClueID == clueID {68+ return &p.Rectangles[i]69+ }70+ }71+ return nil72+}73+74+// CellOwner returns the clue ID of the rectangle covering (x, y), or -1.75+func (p *Puzzle) CellOwner(x, y int) int {76+ for _, r := range p.Rectangles {77+ if r.Contains(x, y) {78+ return r.ClueID79+ }80+ }81+ return -182+}83+84+// Overlaps reports whether the given rectangle overlaps any existing rectangle,85+// optionally excluding the rectangle belonging to excludeClueID.86+func (p *Puzzle) Overlaps(rect Rectangle, excludeClueID int) bool {87+ for _, r := range p.Rectangles {88+ if r.ClueID == excludeClueID {89+ continue90+ }91+ if rectsOverlap(rect, r) {92+ return true93+ }94+ }95+ return false96+}97+98+func rectsOverlap(a, b Rectangle) bool {99+ return a.X < b.X+b.W && a.X+a.W > b.X && a.Y < b.Y+b.H && a.Y+a.H > b.Y100+}101+102+// SetRectangle places or replaces the rectangle for a clue.103+func (p *Puzzle) SetRectangle(rect Rectangle) {104+ for i, r := range p.Rectangles {105+ if r.ClueID == rect.ClueID {106+ p.Rectangles[i] = rect107+ return108+ }109+ }110+ p.Rectangles = append(p.Rectangles, rect)111+}112+113+// RemoveRectangle removes the rectangle for the given clue ID.114+func (p *Puzzle) RemoveRectangle(clueID int) {115+ for i, r := range p.Rectangles {116+ if r.ClueID == clueID {117+ p.Rectangles = append(p.Rectangles[:i], p.Rectangles[i+1:]...)118+ return119+ }120+ }121+}122+123+// IsSolved reports whether the puzzle is complete:124+// every cell is covered exactly once and each rectangle contains125+// exactly one clue whose value equals the rectangle's area.126+func (p *Puzzle) IsSolved() bool {127+ if len(p.Rectangles) != len(p.Clues) {128+ return false129+ }130+131+ // Check each rectangle has exactly one clue with matching area.132+ for _, r := range p.Rectangles {133+ clue := p.FindClueByID(r.ClueID)134+ if clue == nil {135+ return false136+ }137+ if r.Area() != clue.Value {138+ return false139+ }140+ if !r.Contains(clue.X, clue.Y) {141+ return false142+ }143+ // Check no other clue is inside this rectangle.144+ clueCount := 0145+ for _, c := range p.Clues {146+ if r.Contains(c.X, c.Y) {147+ clueCount++148+ }149+ }150+ if clueCount != 1 {151+ return false152+ }153+ }154+155+ // Check all cells are covered exactly once (no gaps, no overlaps).156+ covered := make([][]bool, p.Height)157+ for y := range p.Height {158+ covered[y] = make([]bool, p.Width)159+ }160+ for _, r := range p.Rectangles {161+ for dy := range r.H {162+ for dx := range r.W {163+ cx, cy := r.X+dx, r.Y+dy164+ if cx < 0 || cx >= p.Width || cy < 0 || cy >= p.Height {165+ return false166+ }167+ if covered[cy][cx] {168+ return false // overlap169+ }170+ covered[cy][cx] = true171+ }172+ }173+ }174+ for y := range p.Height {175+ for x := range p.Width {176+ if !covered[y][x] {177+ return false178+ }179+ }180+ }181+182+ return true183+}@@ -0,0 +1,527 @@
1+package shikaku2+3+import (4+ "encoding/json"5+ "math/rand/v2"6+ "testing"7+)8+9+// --- helpers ---10+11+// simplePuzzle builds a 4x4 puzzle with 4 clues:12+//13+// [4] . . .14+// . . . .15+// . . [2] .16+// . [6] . .17+//18+// Solution: 2x2 rect for clue 0, 1x2 rect for clue 2, 2x3 rect for clue 1, ...19+func simplePuzzle() *Puzzle {20+ return &Puzzle{21+ Width: 4,22+ Height: 4,23+ Clues: []Clue{24+ {ID: 0, X: 0, Y: 0, Value: 4},25+ {ID: 1, X: 1, Y: 3, Value: 6},26+ {ID: 2, X: 2, Y: 2, Value: 2},27+ {ID: 3, X: 3, Y: 0, Value: 4},28+ },29+ }30+}31+32+// tinyPuzzle builds a 2x2 puzzle with 2 clues for minimal testing.33+func tinyPuzzle() *Puzzle {34+ return &Puzzle{35+ Width: 2,36+ Height: 2,37+ Clues: []Clue{38+ {ID: 0, X: 0, Y: 0, Value: 2},39+ {ID: 1, X: 1, Y: 1, Value: 2},40+ },41+ }42+}43+44+// --- FindClueAt ---45+46+func TestFindClueAt(t *testing.T) {47+ t.Run("clue exists", func(t *testing.T) {48+ p := simplePuzzle()49+ c := p.FindClueAt(0, 0)50+ if c == nil {51+ t.Fatal("expected clue at (0,0)")52+ }53+ if c.ID != 0 || c.Value != 4 {54+ t.Errorf("clue = %+v, want ID=0 Value=4", c)55+ }56+ })57+58+ t.Run("no clue", func(t *testing.T) {59+ p := simplePuzzle()60+ c := p.FindClueAt(1, 1)61+ if c != nil {62+ t.Errorf("expected nil at (1,1), got %+v", c)63+ }64+ })65+66+ t.Run("lazy index build", func(t *testing.T) {67+ p := simplePuzzle()68+ if p.clueIndex != nil {69+ t.Fatal("clueIndex should be nil before first call")70+ }71+ _ = p.FindClueAt(0, 0)72+ if p.clueIndex == nil {73+ t.Error("clueIndex should be built after first call")74+ }75+ })76+}77+78+// --- FindClueByID ---79+80+func TestFindClueByID(t *testing.T) {81+ t.Run("found by sequential ID", func(t *testing.T) {82+ p := simplePuzzle()83+ c := p.FindClueByID(2)84+ if c == nil {85+ t.Fatal("expected clue with ID=2")86+ }87+ if c.X != 2 || c.Y != 2 {88+ t.Errorf("clue 2 at (%d,%d), want (2,2)", c.X, c.Y)89+ }90+ })91+92+ t.Run("invalid ID", func(t *testing.T) {93+ p := simplePuzzle()94+ if c := p.FindClueByID(99); c != nil {95+ t.Errorf("expected nil, got %+v", c)96+ }97+ })98+}99+100+// --- Rectangle methods ---101+102+func TestRectangleContains(t *testing.T) {103+ r := Rectangle{ClueID: 0, X: 1, Y: 1, W: 2, H: 3}104+105+ if !r.Contains(1, 1) {106+ t.Error("should contain top-left corner")107+ }108+ if !r.Contains(2, 3) {109+ t.Error("should contain bottom-right cell")110+ }111+ if r.Contains(0, 0) {112+ t.Error("should not contain (0,0)")113+ }114+ if r.Contains(3, 1) {115+ t.Error("should not contain cell just outside right edge")116+ }117+}118+119+func TestRectangleArea(t *testing.T) {120+ r := Rectangle{W: 3, H: 4}121+ if r.Area() != 12 {122+ t.Errorf("Area() = %d, want 12", r.Area())123+ }124+}125+126+// --- CellOwner ---127+128+func TestCellOwner(t *testing.T) {129+ p := simplePuzzle()130+ p.SetRectangle(Rectangle{ClueID: 0, X: 0, Y: 0, W: 2, H: 2})131+132+ t.Run("owned cell", func(t *testing.T) {133+ if owner := p.CellOwner(1, 1); owner != 0 {134+ t.Errorf("CellOwner(1,1) = %d, want 0", owner)135+ }136+ })137+138+ t.Run("unowned cell", func(t *testing.T) {139+ if owner := p.CellOwner(3, 3); owner != -1 {140+ t.Errorf("CellOwner(3,3) = %d, want -1", owner)141+ }142+ })143+}144+145+// --- Overlaps ---146+147+func TestOverlaps(t *testing.T) {148+ p := simplePuzzle()149+ p.SetRectangle(Rectangle{ClueID: 0, X: 0, Y: 0, W: 2, H: 2})150+151+ t.Run("overlapping", func(t *testing.T) {152+ r := Rectangle{ClueID: 1, X: 1, Y: 1, W: 2, H: 2}153+ if !p.Overlaps(r, 1) {154+ t.Error("should overlap with existing rectangle")155+ }156+ })157+158+ t.Run("non-overlapping", func(t *testing.T) {159+ r := Rectangle{ClueID: 1, X: 2, Y: 0, W: 2, H: 2}160+ if p.Overlaps(r, 1) {161+ t.Error("should not overlap")162+ }163+ })164+165+ t.Run("exclude self", func(t *testing.T) {166+ // Replacing the same clue's rectangle should not count as overlap.167+ r := Rectangle{ClueID: 0, X: 0, Y: 0, W: 2, H: 2}168+ if p.Overlaps(r, 0) {169+ t.Error("should not overlap when excluding self")170+ }171+ })172+}173+174+// --- SetRectangle / RemoveRectangle ---175+176+func TestSetAndRemoveRectangle(t *testing.T) {177+ p := simplePuzzle()178+179+ t.Run("set new", func(t *testing.T) {180+ p.SetRectangle(Rectangle{ClueID: 0, X: 0, Y: 0, W: 2, H: 2})181+ if r := p.FindRectangleForClue(0); r == nil {182+ t.Fatal("expected rectangle for clue 0")183+ }184+ })185+186+ t.Run("replace existing", func(t *testing.T) {187+ p.SetRectangle(Rectangle{ClueID: 0, X: 0, Y: 0, W: 4, H: 1})188+ r := p.FindRectangleForClue(0)189+ if r == nil {190+ t.Fatal("expected rectangle for clue 0")191+ }192+ if r.W != 4 || r.H != 1 {193+ t.Errorf("rectangle = %dx%d, want 4x1", r.W, r.H)194+ }195+ if len(p.Rectangles) != 1 {196+ t.Errorf("len(Rectangles) = %d, want 1", len(p.Rectangles))197+ }198+ })199+200+ t.Run("remove", func(t *testing.T) {201+ p.RemoveRectangle(0)202+ if r := p.FindRectangleForClue(0); r != nil {203+ t.Error("expected nil after removal")204+ }205+ })206+207+ t.Run("remove nonexistent", func(t *testing.T) {208+ p.RemoveRectangle(99) // must not panic209+ })210+}211+212+// --- IsSolved ---213+214+func TestIsSolved(t *testing.T) {215+ t.Run("solved 2x2", func(t *testing.T) {216+ p := tinyPuzzle()217+ p.SetRectangle(Rectangle{ClueID: 0, X: 0, Y: 0, W: 1, H: 2})218+ p.SetRectangle(Rectangle{ClueID: 1, X: 1, Y: 0, W: 1, H: 2})219+220+ if !p.IsSolved() {221+ t.Error("puzzle should be solved")222+ }223+ })224+225+ t.Run("incomplete coverage", func(t *testing.T) {226+ p := tinyPuzzle()227+ p.SetRectangle(Rectangle{ClueID: 0, X: 0, Y: 0, W: 1, H: 2})228+ // Only one rectangle placed.229+230+ if p.IsSolved() {231+ t.Error("incomplete coverage should not be solved")232+ }233+ })234+235+ t.Run("wrong area", func(t *testing.T) {236+ p := tinyPuzzle()237+ // Both clues want area 2 but we give them area 1 rects238+ // (which won't cover everything either).239+ p.SetRectangle(Rectangle{ClueID: 0, X: 0, Y: 0, W: 1, H: 1})240+ p.SetRectangle(Rectangle{ClueID: 1, X: 1, Y: 1, W: 1, H: 1})241+242+ if p.IsSolved() {243+ t.Error("wrong area should not be solved")244+ }245+ })246+247+ t.Run("overlapping rectangles", func(t *testing.T) {248+ p := tinyPuzzle()249+ p.SetRectangle(Rectangle{ClueID: 0, X: 0, Y: 0, W: 2, H: 1})250+ p.SetRectangle(Rectangle{ClueID: 1, X: 0, Y: 0, W: 2, H: 1}) // same area251+252+ if p.IsSolved() {253+ t.Error("overlapping rectangles should not be solved")254+ }255+ })256+257+ t.Run("no rectangles", func(t *testing.T) {258+ p := simplePuzzle()259+ if p.IsSolved() {260+ t.Error("no rectangles should not be solved")261+ }262+ })263+264+ t.Run("clue outside its rectangle", func(t *testing.T) {265+ p := &Puzzle{266+ Width: 2,267+ Height: 2,268+ Clues: []Clue{269+ {ID: 0, X: 0, Y: 0, Value: 2},270+ {ID: 1, X: 1, Y: 0, Value: 2},271+ },272+ }273+ // Place clue 0's rect in row 1 (doesn't contain clue 0).274+ p.SetRectangle(Rectangle{ClueID: 0, X: 0, Y: 1, W: 2, H: 1})275+ p.SetRectangle(Rectangle{ClueID: 1, X: 0, Y: 0, W: 2, H: 1})276+277+ if p.IsSolved() {278+ t.Error("clue outside its rectangle should not be solved")279+ }280+ })281+282+ t.Run("two clues in same rectangle", func(t *testing.T) {283+ p := &Puzzle{284+ Width: 4,285+ Height: 1,286+ Clues: []Clue{287+ {ID: 0, X: 0, Y: 0, Value: 4},288+ {ID: 1, X: 3, Y: 0, Value: 4},289+ },290+ }291+ // One huge rectangle containing both clues.292+ p.SetRectangle(Rectangle{ClueID: 0, X: 0, Y: 0, W: 4, H: 1})293+ // No rect for clue 1 — count mismatch.294+ if p.IsSolved() {295+ t.Error("two clues in same rectangle should not be solved")296+ }297+ })298+}299+300+// --- expansion.rect ---301+302+func TestExpansionRect(t *testing.T) {303+ clue := &Clue{ID: 0, X: 3, Y: 3, Value: 6}304+ e := expansion{clueID: 0, left: 1, right: 2, up: 0, down: 1}305+ r := e.rect(clue)306+307+ if r.X != 2 || r.Y != 3 || r.W != 4 || r.H != 2 {308+ t.Errorf("rect = (%d,%d) %dx%d, want (2,3) 4x2", r.X, r.Y, r.W, r.H)309+ }310+ if r.Area() != 8 {311+ t.Errorf("Area() = %d, want 8", r.Area())312+ }313+}314+315+// --- Save/Load round-trip ---316+317+func TestSaveLoadRoundTrip(t *testing.T) {318+ t.Run("full round trip", func(t *testing.T) {319+ original := &Model{320+ puzzle: Puzzle{321+ Width: 5,322+ Height: 5,323+ Clues: []Clue{324+ {ID: 0, X: 0, Y: 0, Value: 4},325+ {ID: 1, X: 2, Y: 0, Value: 6},326+ {ID: 2, X: 0, Y: 2, Value: 3},327+ },328+ Rectangles: []Rectangle{329+ {ClueID: 0, X: 0, Y: 0, W: 2, H: 2},330+ {ClueID: 1, X: 2, Y: 0, W: 3, H: 2},331+ },332+ },333+ keys: DefaultKeyMap,334+ modeTitle: "Test Mode",335+ }336+337+ data, err := original.GetSave()338+ if err != nil {339+ t.Fatal(err)340+ }341+342+ got, err := ImportModel(data)343+ if err != nil {344+ t.Fatal(err)345+ }346+347+ if got.puzzle.Width != 5 || got.puzzle.Height != 5 {348+ t.Errorf("dimensions = %dx%d, want 5x5", got.puzzle.Width, got.puzzle.Height)349+ }350+ if len(got.puzzle.Clues) != 3 {351+ t.Fatalf("clue count = %d, want 3", len(got.puzzle.Clues))352+ }353+ if len(got.puzzle.Rectangles) != 2 {354+ t.Fatalf("rectangle count = %d, want 2", len(got.puzzle.Rectangles))355+ }356+ if got.modeTitle != "Test Mode" {357+ t.Errorf("modeTitle = %q, want %q", got.modeTitle, "Test Mode")358+ }359+360+ for i, c := range original.puzzle.Clues {361+ g := got.puzzle.Clues[i]362+ if g.ID != c.ID || g.X != c.X || g.Y != c.Y || g.Value != c.Value {363+ t.Errorf("clue[%d] = %+v, want %+v", i, g, c)364+ }365+ }366+ for i, r := range original.puzzle.Rectangles {367+ g := got.puzzle.Rectangles[i]368+ if g.ClueID != r.ClueID || g.X != r.X || g.Y != r.Y || g.W != r.W || g.H != r.H {369+ t.Errorf("rectangle[%d] = %+v, want %+v", i, g, r)370+ }371+ }372+ })373+374+ t.Run("invalid JSON", func(t *testing.T) {375+ _, err := ImportModel([]byte("not json"))376+ if err == nil {377+ t.Fatal("expected error for invalid JSON")378+ }379+ })380+}381+382+func TestSaveJSON(t *testing.T) {383+ m := Model{384+ puzzle: Puzzle{385+ Width: 4,386+ Height: 4,387+ Clues: []Clue{388+ {ID: 0, X: 0, Y: 0, Value: 4},389+ },390+ Rectangles: []Rectangle{391+ {ClueID: 0, X: 0, Y: 0, W: 2, H: 2},392+ },393+ },394+ keys: DefaultKeyMap,395+ modeTitle: "Test",396+ }397+398+ data, err := m.GetSave()399+ if err != nil {400+ t.Fatal(err)401+ }402+403+ var raw map[string]json.RawMessage404+ if err := json.Unmarshal(data, &raw); err != nil {405+ t.Fatal(err)406+ }407+408+ for _, key := range []string{"width", "height", "clues", "rectangles", "mode_title"} {409+ if _, ok := raw[key]; !ok {410+ t.Errorf("missing key %q in save JSON", key)411+ }412+ }413+}414+415+// --- Generator ---416+417+func TestGeneratePuzzle(t *testing.T) {418+ t.Run("produces valid puzzle", func(t *testing.T) {419+ puzzle, err := GeneratePuzzle(5, 5, 5)420+ if err != nil {421+ t.Fatal(err)422+ }423+ if puzzle.Width != 5 || puzzle.Height != 5 {424+ t.Errorf("dimensions = %dx%d, want 5x5", puzzle.Width, puzzle.Height)425+ }426+ if len(puzzle.Clues) == 0 {427+ t.Error("expected at least one clue")428+ }429+ // Sum of clue values should equal grid area.430+ sum := 0431+ for _, c := range puzzle.Clues {432+ sum += c.Value433+ }434+ if sum != 25 {435+ t.Errorf("sum of clue values = %d, want 25", sum)436+ }437+ })438+439+ t.Run("no rectangles placed", func(t *testing.T) {440+ puzzle, err := GeneratePuzzle(5, 5, 5)441+ if err != nil {442+ t.Fatal(err)443+ }444+ if len(puzzle.Rectangles) != 0 {445+ t.Errorf("len(Rectangles) = %d, want 0", len(puzzle.Rectangles))446+ }447+ })448+449+ t.Run("clues within bounds", func(t *testing.T) {450+ puzzle, err := GeneratePuzzle(7, 7, 8)451+ if err != nil {452+ t.Fatal(err)453+ }454+ for _, c := range puzzle.Clues {455+ if c.X < 0 || c.X >= 7 || c.Y < 0 || c.Y >= 7 {456+ t.Errorf("clue %d at (%d,%d) out of bounds", c.ID, c.X, c.Y)457+ }458+ }459+ })460+461+ t.Run("seeded deterministic", func(t *testing.T) {462+ rng1 := rand.New(rand.NewPCG(42, 42))463+ rng2 := rand.New(rand.NewPCG(42, 42))464+465+ p1, err := GeneratePuzzleSeeded(5, 5, 5, rng1)466+ if err != nil {467+ t.Fatal(err)468+ }469+ p2, err := GeneratePuzzleSeeded(5, 5, 5, rng2)470+ if err != nil {471+ t.Fatal(err)472+ }473+474+ if len(p1.Clues) != len(p2.Clues) {475+ t.Fatalf("different clue counts: %d vs %d", len(p1.Clues), len(p2.Clues))476+ }477+ for i := range p1.Clues {478+ if p1.Clues[i] != p2.Clues[i] {479+ t.Errorf("clue[%d] differs: %+v vs %+v", i, p1.Clues[i], p2.Clues[i])480+ }481+ }482+ })483+484+ t.Run("larger grid", func(t *testing.T) {485+ puzzle, err := GeneratePuzzle(10, 10, 15)486+ if err != nil {487+ t.Fatal(err)488+ }489+ sum := 0490+ for _, c := range puzzle.Clues {491+ sum += c.Value492+ }493+ if sum != 100 {494+ t.Errorf("sum of clue values = %d, want 100", sum)495+ }496+ })497+}498+499+// --- Mode Spawn ---500+501+func TestModeSpawn(t *testing.T) {502+ mode := NewMode("Test", "test mode", 5, 5, 5)503+504+ t.Run("Spawn", func(t *testing.T) {505+ g, err := mode.Spawn()506+ if err != nil {507+ t.Fatal(err)508+ }509+ if g == nil {510+ t.Fatal("expected non-nil game")511+ }512+ if g.IsSolved() {513+ t.Error("new game should not be solved")514+ }515+ })516+517+ t.Run("SpawnSeeded", func(t *testing.T) {518+ rng := rand.New(rand.NewPCG(123, 456))519+ g, err := mode.SpawnSeeded(rng)520+ if err != nil {521+ t.Fatal(err)522+ }523+ if g == nil {524+ t.Fatal("expected non-nil game")525+ }526+ })527+}@@ -0,0 +1,193 @@
1+package shikaku2+3+import (4+ "fmt"5+ "strings"6+7+ "github.com/FelineStateMachine/puzzletea/game"8+ "github.com/charmbracelet/lipgloss"9+)10+11+const cellWidth = 312+13+// 16-color palette for placed rectangles (ANSI 256).14+var rectColors = []lipgloss.AdaptiveColor{15+ {Dark: "60", Light: "60"}, // muted purple16+ {Dark: "66", Light: "66"}, // teal17+ {Dark: "95", Light: "95"}, // mauve18+ {Dark: "130", Light: "130"}, // brown/amber19+ {Dark: "71", Light: "71"}, // olive green20+ {Dark: "132", Light: "132"}, // dusty rose21+ {Dark: "37", Light: "37"}, // cyan22+ {Dark: "136", Light: "136"}, // dark gold23+ {Dark: "97", Light: "97"}, // plum24+ {Dark: "29", Light: "29"}, // forest green25+ {Dark: "167", Light: "167"}, // salmon26+ {Dark: "68", Light: "68"}, // steel blue27+ {Dark: "94", Light: "94"}, // rust28+ {Dark: "139", Light: "139"}, // lavender29+ {Dark: "28", Light: "28"}, // deep green30+ {Dark: "133", Light: "133"}, // orchid31+}32+33+var (34+ baseStyle = lipgloss.NewStyle()35+36+ emptyDotColor = lipgloss.AdaptiveColor{Dark: "242", Light: "249"}37+ clueColor = lipgloss.AdaptiveColor{Dark: "230", Light: "236"}38+ clueBgColor = lipgloss.AdaptiveColor{Dark: "238", Light: "254"}39+40+ cursorBgColor = lipgloss.AdaptiveColor{Dark: "214", Light: "173"}41+ cursorFgColor = lipgloss.AdaptiveColor{Dark: "235", Light: "255"}42+43+ previewGoodBg = lipgloss.AdaptiveColor{Dark: "28", Light: "157"}44+ previewBadBg = lipgloss.AdaptiveColor{Dark: "124", Light: "217"}45+46+ solvedBg = lipgloss.AdaptiveColor{Dark: "22", Light: "151"}47+ solvedBorder = lipgloss.AdaptiveColor{Dark: "149", Light: "22"}48+ borderColor = lipgloss.AdaptiveColor{Dark: "240", Light: "250"}49+50+ selectedClueBg = lipgloss.AdaptiveColor{Dark: "172", Light: "179"}51+52+ infoSatisfied = lipgloss.AdaptiveColor{Dark: "22", Light: "149"}53+ infoText = lipgloss.AdaptiveColor{Dark: "137", Light: "137"}54+55+ statusBarStyle = lipgloss.NewStyle().56+ Foreground(lipgloss.AdaptiveColor{Light: "244", Dark: "244"}).57+ MarginTop(1)58+59+ gridBorderStyle = lipgloss.NewStyle().60+ Border(lipgloss.RoundedBorder()).61+ Padding(1).62+ BorderForeground(borderColor)63+64+ gridBorderSolvedStyle = lipgloss.NewStyle().65+ Border(lipgloss.RoundedBorder()).66+ Padding(1).67+ BorderForeground(solvedBorder)68+)69+70+func gridView(m Model, solved bool) string {71+ // Build preview rectangle if in expansion mode.72+ var preview *Rectangle73+ var previewClue *Clue74+ if m.selectedClue != nil {75+ previewClue = m.puzzle.FindClueByID(*m.selectedClue)76+ if previewClue != nil {77+ r := m.expansion.rect(previewClue)78+ preview = &r79+ }80+ }81+82+ var rows []string83+ for y := range m.puzzle.Height {84+ var cells []string85+ for x := range m.puzzle.Width {86+ cells = append(cells, cellView(m, x, y, solved, preview, previewClue))87+ }88+ rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, cells...))89+ }90+ grid := lipgloss.JoinVertical(lipgloss.Left, rows...)91+92+ if solved {93+ return gridBorderSolvedStyle.Render(grid)94+ }95+ return gridBorderStyle.Render(grid)96+}97+98+func cellView(m Model, x, y int, solved bool, preview *Rectangle, previewClue *Clue) string {99+ isCursor := m.cursor.X == x && m.cursor.Y == y100+ clue := m.puzzle.FindClueAt(x, y)101+ owner := m.puzzle.CellOwner(x, y)102+103+ // Determine display text.104+ display := " · "105+ if clue != nil {106+ display = fmt.Sprintf("%2d ", clue.Value)107+ if clue.Value < 10 {108+ display = fmt.Sprintf(" %d ", clue.Value)109+ }110+ }111+112+ // Base style.113+ s := baseStyle.Foreground(emptyDotColor)114+115+ if clue != nil {116+ s = baseStyle.Foreground(clueColor).Background(clueBgColor).Bold(true)117+ }118+119+ // Rectangle background color.120+ if owner >= 0 {121+ colorIdx := owner % len(rectColors)122+ s = s.Background(rectColors[colorIdx]).Foreground(lipgloss.AdaptiveColor{Dark: "255", Light: "255"})123+ if clue != nil {124+ s = s.Foreground(lipgloss.AdaptiveColor{Dark: "255", Light: "255"}).Bold(true)125+ }126+ }127+128+ // Preview overlay.129+ inPreview := preview != nil && preview.Contains(x, y)130+ if inPreview && previewClue != nil {131+ if preview.Area() == previewClue.Value && !m.puzzle.Overlaps(*preview, previewClue.ID) {132+ s = s.Background(previewGoodBg).Foreground(lipgloss.AdaptiveColor{Dark: "255", Light: "255"})133+ } else {134+ s = s.Background(previewBadBg).Foreground(lipgloss.AdaptiveColor{Dark: "255", Light: "255"})135+ }136+ }137+138+ // Selected clue highlight.139+ if m.selectedClue != nil && clue != nil && clue.ID == *m.selectedClue && !inPreview {140+ s = s.Background(selectedClueBg).Foreground(lipgloss.AdaptiveColor{Dark: "255", Light: "235"}).Bold(true)141+ }142+143+ // Solved styling.144+ if solved {145+ if owner >= 0 {146+ colorIdx := owner % len(rectColors)147+ s = s.Background(rectColors[colorIdx]).Foreground(lipgloss.AdaptiveColor{Dark: "255", Light: "255"})148+ } else {149+ s = s.Background(solvedBg)150+ }151+ if isCursor {152+ s = game.CursorSolvedStyle153+ }154+ } else if isCursor {155+ s = s.Background(cursorBgColor).Foreground(cursorFgColor).Bold(true)156+ }157+158+ return s.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(display)159+}160+161+func infoView(p *Puzzle) string {162+ placed := len(p.Rectangles)163+ total := len(p.Clues)164+ correct := 0165+ for _, r := range p.Rectangles {166+ clue := p.FindClueByID(r.ClueID)167+ if clue != nil && r.Area() == clue.Value {168+ correct++169+ }170+ }171+172+ satisfiedStyle := lipgloss.NewStyle().Foreground(infoSatisfied)173+ infoStyle := lipgloss.NewStyle().Foreground(infoText)174+175+ var sb strings.Builder176+ sb.WriteString(infoStyle.Render("Rectangles: "))177+ sb.WriteString(satisfiedStyle.Render(fmt.Sprintf("%d", correct)))178+ sb.WriteString(infoStyle.Render(fmt.Sprintf("/%d correct Placed: %d", total, placed)))179+ return sb.String()180+}181+182+func statusBarView(selected, showFullHelp bool) string {183+ if selected {184+ if showFullHelp {185+ return statusBarStyle.Render("arrows: expand shift+arrows: shrink enter: confirm esc: cancel bkspc: delete ctrl+n: menu ctrl+r: reset ctrl+h: help")186+ }187+ return statusBarStyle.Render("arrows: expand shift+arrows: shrink enter: confirm esc: cancel")188+ }189+ if showFullHelp {190+ return statusBarStyle.Render("arrows/wasd: move enter/space: select clue bkspc: delete ctrl+n: menu ctrl+r: reset ctrl+h: help")191+ }192+ return statusBarStyle.Render("enter/space: select clue bkspc: delete")193+}@@ -262,15 +262,19 @@ func (m model) handleHelpSelectEnter() (tea.Model, tea.Cmd) {
262262 }263263 m.helpCategory = cat264264 265- renderer, err := glamour.NewTermRenderer(266- glamour.WithAutoStyle(),267- glamour.WithWordWrap(m.width),268- )269- if err != nil {270- log.Printf("failed to create help renderer: %v", err)271- return m, nil265+ if m.helpRenderer == nil || m.helpRendererWidth != m.width {266+ renderer, err := glamour.NewTermRenderer(267+ glamour.WithAutoStyle(),268+ glamour.WithWordWrap(m.width),269+ )270+ if err != nil {271+ log.Printf("failed to create help renderer: %v", err)272+ return m, nil273+ }274+ m.helpRenderer = renderer275+ m.helpRendererWidth = m.width272276 }273- rendered, err := renderer.Render(cat.Help)277+ rendered, err := m.helpRenderer.Render(cat.Help)274278 if err != nil {275279 log.Printf("failed to render help: %v", err)276280 return m, nil@@ -0,0 +1,94 @@
1+Output vhs/shikaku.gif2+3+Set Theme "catppuccin-mocha"4+Set FontSize 145+Set Width 8006+Set Height 8007+Set Margin 608+Set MarginFill "#7A6855"9+Set WindowBar Colorful10+Set BorderRadius 1011+12+Env COLORTERM "truecolor"13+Env CLICOLOR_FORCE "1"14+Env CI ""15+16+Hide17+Type "go build -o puzzletea"18+Enter19+Sleep 2s20+Ctrl+L21+Show22+23+Type "./puzzletea new shikaku easy-7x7"24+Sleep 500ms25+Enter26+Sleep 2s27+28+# Navigate to a clue29+Right30+Sleep 400ms31+Down32+Sleep 400ms33+34+# Select clue to enter expansion mode35+Enter36+Sleep 500ms37+38+# Expand rightward39+Right40+Sleep 400ms41+Right42+Sleep 400ms43+44+# Expand downward45+Down46+Sleep 400ms47+48+# Confirm placement49+Enter50+Sleep 500ms51+52+# Navigate to another clue53+Down54+Sleep 400ms55+Down56+Sleep 400ms57+58+# Select and expand59+Enter60+Sleep 500ms61+Right62+Sleep 400ms63+Right64+Sleep 400ms65+Right66+Sleep 400ms67+68+# Shrink one step back69+Type "L"70+Sleep 400ms71+72+# Confirm73+Enter74+Sleep 500ms75+76+# Navigate to another clue77+Left78+Sleep 400ms79+Left80+Sleep 400ms81+82+# Select and expand vertically83+Enter84+Sleep 500ms85+Down86+Sleep 400ms87+Down88+Sleep 400ms89+90+# Cancel to discard preview91+Escape92+Sleep 500ms93+94+Sleep 3s