Skip to content

Commit 943c28a

Browse files
committed
✨ (sudoku-game): add puzzle generator and is safe test
1 parent 558688c commit 943c28a

File tree

8 files changed

+353
-5
lines changed

8 files changed

+353
-5
lines changed

internal/game/board.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package game
2+
3+
import "fmt"
4+
5+
// 檢查 row, col 位置是否可以放置 num
6+
func (board *Board) isSafe(row, col, num int) bool {
7+
// 檢查行與列是否有放相同的值
8+
for i := 0; i < BoardSize; i++ {
9+
if board.Cells[row][i].Value == num ||
10+
board.Cells[i][col].Value == num {
11+
return false
12+
}
13+
}
14+
15+
// 檢查 Box 內是否有相同的值
16+
boxRow := (row / BoxSize) * BoxSize
17+
boxCol := (col / BoxSize) * BoxSize
18+
for br := 0; br < BoxSize; br++ {
19+
for bc := 0; bc < BoxSize; bc++ {
20+
if board.Cells[boxRow+br][boxCol+bc].Value == num {
21+
return false
22+
}
23+
}
24+
}
25+
26+
// 檢查 num 值介於 1 到 9
27+
if num < 1 || num > 9 {
28+
return false
29+
}
30+
return true
31+
}
32+
33+
func (board *Board) Clone() Board {
34+
copyBoard := *board
35+
return copyBoard
36+
}
37+
38+
func (board *Board) String() string {
39+
out := ""
40+
for r := 0; r < BoardSize; r++ {
41+
if r%3 == 0 {
42+
out += "+-------+-------+-------+\n"
43+
}
44+
for c := 0; c < BoardSize; c++ {
45+
if c%3 == 0 {
46+
out += "| "
47+
}
48+
if board.Cells[r][c].Value == 0 {
49+
out += ". "
50+
}
51+
if board.Cells[r][c].Type == Preset {
52+
out += fmt.Sprintf("%d ", board.Cells[r][c].Value)
53+
}
54+
}
55+
out += "|\n"
56+
}
57+
out += "+-------+-------+-------+\n"
58+
return out
59+
}

internal/game/board_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package game
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
func TestIsSafe(t *testing.T) {
10+
type coord struct {
11+
Row int
12+
Col int
13+
}
14+
tests := []struct {
15+
name string
16+
targetCoord coord
17+
targetValue int
18+
setup func() *Board
19+
want bool
20+
}{
21+
{
22+
name: "check if board is safe to put 1, 2 with value 9, should be false, for setup board.Cells[1][1].Value=9",
23+
targetCoord: coord{
24+
Row: 1,
25+
Col: 2,
26+
},
27+
targetValue: 9,
28+
setup: func() *Board {
29+
board := NewBoard()
30+
board.Cells[1][1].Type = Preset
31+
board.Cells[1][1].Value = 9
32+
return board
33+
},
34+
want: false,
35+
},
36+
{
37+
name: "check if board is safe to put 2, 5 with value 9, should be true, for setup board.Cells[1][1].Value=9, boards.Cells[4][3].Value = 9",
38+
targetCoord: coord{
39+
Row: 2,
40+
Col: 5,
41+
},
42+
targetValue: 9,
43+
setup: func() *Board {
44+
board := NewBoard()
45+
board.Cells[1][1].Type = Preset
46+
board.Cells[1][1].Value = 9
47+
board.Cells[4][3].Type = Preset
48+
board.Cells[4][3].Value = 9
49+
return board
50+
},
51+
want: true,
52+
},
53+
}
54+
for _, tt := range tests {
55+
t.Run(tt.name, func(t *testing.T) {
56+
board := tt.setup()
57+
got := board.isSafe(tt.targetCoord.Row, tt.targetCoord.Col, tt.targetValue)
58+
assert.Equal(t, tt.want, got)
59+
})
60+
}
61+
}

internal/game/difficulty.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package game
2+
3+
type Difficulty int
4+
5+
const (
6+
Easy Difficulty = 36
7+
Medium Difficulty = 32
8+
Hard Difficulty = 28
9+
)

internal/game/puzzle.go

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package game
2+
3+
// presetBoard - 填滿格子
4+
func (board *Board) presetBoard() bool {
5+
row, col, foundEmpty := -1, -1, false
6+
// 找到第一個非空的格子來填
7+
for r := 0; r < BoardSize && !foundEmpty; r++ {
8+
for c := 0; c < BoardSize && !foundEmpty; c++ {
9+
if board.Cells[r][c].Value == 0 && board.Cells[r][c].Type != Preset {
10+
row, col, foundEmpty = r, c, true
11+
}
12+
}
13+
}
14+
15+
// 當所有都填滿了回傳 true
16+
if !foundEmpty {
17+
return true
18+
}
19+
20+
// 隨機取值出來填寫
21+
for _, digit := range digitsShuffled() {
22+
// 確認 digit 是否可以填入 row, col
23+
if board.isSafe(row, col, digit) {
24+
// 先填入 row, col 為 digit
25+
board.Cells[row][col].Type = Preset
26+
board.Cells[row][col].Value = digit
27+
// 如果格子填滿則回傳 true
28+
if board.presetBoard() {
29+
return true
30+
}
31+
// 否則把 row, col 回朔
32+
board.Cells[row][col].Type = Empty
33+
board.Cells[row][col].Value = 0
34+
}
35+
}
36+
return false
37+
}
38+
39+
// solveCount - 計算一共有多少解答
40+
func solveCount(board *Board, limit int) int {
41+
row, col, found := -1, -1, false
42+
// 找到第一個非空的格子來填
43+
for r := 0; r < BoardSize && !found; r++ {
44+
for c := 0; c < BoardSize && !found; c++ {
45+
if board.Cells[r][c].Type == Empty {
46+
row, col, found = r, c, true
47+
}
48+
}
49+
}
50+
// 全部非空解答找到了
51+
if !found {
52+
return 1
53+
}
54+
// 開始試著填入值找到解答
55+
count := 0
56+
for _, digit := range digitsShuffled() {
57+
if board.isSafe(row, col, digit) {
58+
// 先填入 row, col 為 digit
59+
board.Cells[row][col].Type = Preset
60+
board.Cells[row][col].Value = digit
61+
// 累加
62+
count += solveCount(board, limit-count)
63+
// 把 row, col 回朔
64+
board.Cells[row][col].Type = Empty
65+
board.Cells[row][col].Value = 0
66+
if count >= limit {
67+
return count
68+
}
69+
}
70+
}
71+
72+
return count
73+
}
74+
75+
// hasUniqueSolution - 是否具有唯一解
76+
func (board *Board) hasUniqueSolution() bool {
77+
copyBoard := *board
78+
count := solveCount(&copyBoard, 2)
79+
return count == 1
80+
}
81+
82+
// GenerateSolution - 產生解法
83+
func (board *Board) GenerateSolution() {
84+
// 填入解法
85+
board.presetBoard()
86+
}
87+
88+
// presetedCount - 計算被先填入的 count
89+
func (board *Board) presetedCount() int {
90+
count := 0
91+
for row := 0; row < BoardSize; row++ {
92+
for col := 0; col < BoardSize; col++ {
93+
if board.Cells[row][col].Type == Preset {
94+
count++
95+
}
96+
}
97+
}
98+
return count
99+
}
100+
101+
// MakePuzzleFromSolution - 建立題目
102+
func (board *Board) MakePuzzleFromSolution(targetClues int) {
103+
puzzle := board.Clone()
104+
order := coordsShuffled()
105+
for _, rc := range order {
106+
if puzzle.presetedCount() <= targetClues {
107+
break
108+
}
109+
r, c := rc[0], rc[1]
110+
if puzzle.Cells[r][c].Type == Empty {
111+
continue
112+
}
113+
tmp := puzzle.Cells[r][c]
114+
puzzle.Cells[r][c].Type = Empty
115+
puzzle.Cells[r][c].Value = 0
116+
if puzzle.hasUniqueSolution() {
117+
// 不是唯一解 → 復原
118+
puzzle.Cells[r][c].Type = tmp.Type
119+
puzzle.Cells[r][c].Value = tmp.Value
120+
}
121+
}
122+
board = &puzzle
123+
}

internal/game/puzzle_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package game
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
func TestMakePuzzleFromSolution(t *testing.T) {
10+
tests := []struct {
11+
name string
12+
targetClues int
13+
wantCluesCount int
14+
setup func() *Board
15+
}{
16+
{
17+
name: "generate puzzle with Easy Level",
18+
targetClues: int(Easy),
19+
wantCluesCount: int(Easy),
20+
setup: func() *Board {
21+
board := NewBoard()
22+
board.GenerateSolution()
23+
return board
24+
},
25+
},
26+
{
27+
name: "generate puzzle with Medium Level",
28+
targetClues: int(Medium),
29+
wantCluesCount: int(Medium),
30+
setup: func() *Board {
31+
board := NewBoard()
32+
board.GenerateSolution()
33+
return board
34+
},
35+
},
36+
{
37+
name: "generate puzzle with Hard Level",
38+
targetClues: int(Hard),
39+
wantCluesCount: int(Hard),
40+
setup: func() *Board {
41+
board := NewBoard()
42+
board.GenerateSolution()
43+
return board
44+
},
45+
},
46+
}
47+
48+
for _, tt := range tests {
49+
t.Run(tt.name, func(t *testing.T) {
50+
board := tt.setup()
51+
board.MakePuzzleFromSolution(tt.targetClues)
52+
got := board.presetedCount()
53+
assert.Equal(t, tt.wantCluesCount, got)
54+
})
55+
}
56+
}

internal/game/random.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package game
2+
3+
import (
4+
"math/rand"
5+
"time"
6+
)
7+
8+
func init() {
9+
rand.New(rand.NewSource(time.Now().UnixNano()))
10+
}
11+
12+
// digitsShuffled - 取出亂序數字
13+
func digitsShuffled() []int {
14+
digits := []int{1, 2, 3, 4, 5, 6, 7, 8, 9}
15+
rand.Shuffle(len(digits), func(i, j int) { digits[i], digits[j] = digits[j], digits[i] })
16+
return digits
17+
}
18+
19+
// coords - 取出座標資料
20+
func coords() [][2]int {
21+
var coordPairs [][2]int
22+
for row := 0; row < BoardSize; row++ {
23+
for col := 0; col < BoardSize; col++ {
24+
coordPairs = append(coordPairs, [2]int{row, col})
25+
}
26+
}
27+
return coordPairs
28+
}
29+
30+
// coordsShuffled - 取出亂數座標
31+
func coordsShuffled() [][2]int {
32+
coordPairs := coords()
33+
rand.Shuffle(len(coordPairs), func(i, j int) { coordPairs[i], coordPairs[j] = coordPairs[j], coordPairs[i] })
34+
return coordPairs
35+
}

internal/game/sudoku.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
11
package game
22

3+
const (
4+
BoardSize = 9
5+
BoxSize = 3
6+
)
7+
38
// Board - 盤面
49
type Board struct {
5-
Cells [9][9]Cell
10+
Cells [BoardSize][BoardSize]*Cell
611
}
712

813
// NewBoard 建立一個空的數獨盤面
914
func NewBoard() *Board {
1015
board := &Board{}
11-
for row := 0; row < 9; row++ {
12-
for col := 0; col < 9; col++ {
13-
board.Cells[row][col] = Cell{Value: 0, Type: Empty}
16+
for row := 0; row < BoardSize; row++ {
17+
for col := 0; col < BoardSize; col++ {
18+
board.Cells[row][col] = &Cell{Value: 0, Type: Empty}
1419
}
1520
}
1621
return board

internal/game/sudoku_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ func TestNewGame(t *testing.T) {
1414
{
1515
name: "Empty Board",
1616
wantBoard: &Board{
17-
Cells: [9][9]Cell{
17+
Cells: [9][9]*Cell{
1818
{
1919
{0, Empty}, {0, Empty}, {0, Empty},
2020
{0, Empty}, {0, Empty}, {0, Empty},

0 commit comments

Comments
 (0)