From d6c70ed1073f4b6f5d8f984705127f188b36cd1c Mon Sep 17 00:00:00 2001 From: IndieCoderMM Date: Thu, 16 Jan 2025 23:04:29 +0630 Subject: [PATCH 1/4] feat: Add mcts algo --- internal/app/tictactoe/engine/board.go | 95 ++++++++ internal/app/tictactoe/engine/engine.go | 147 ++++++++++++ internal/app/tictactoe/engine/engine_test.go | 157 +++++++++++++ internal/app/tictactoe/engine/mcts.go | 230 +++++++++++++++++++ 4 files changed, 629 insertions(+) create mode 100644 internal/app/tictactoe/engine/board.go create mode 100644 internal/app/tictactoe/engine/engine.go create mode 100644 internal/app/tictactoe/engine/engine_test.go create mode 100644 internal/app/tictactoe/engine/mcts.go diff --git a/internal/app/tictactoe/engine/board.go b/internal/app/tictactoe/engine/board.go new file mode 100644 index 0000000..6a5e29d --- /dev/null +++ b/internal/app/tictactoe/engine/board.go @@ -0,0 +1,95 @@ +package engine + +import "fmt" + +const ( + P1 = 1 + P2 = -1 + EMPTY = 0 +) + +type TictactoeBoard interface { + GetCell(row, col int) (int, error) + SetCell(row, col int, player int) error + GetRowCol(index int) (int, int) +} + +type Board struct { + Size int + Cells []int +} + +func NewBoard(size int) *Board { + cells := make([]int, size*size) + for i := range cells { + cells[i] = EMPTY + } + + return &Board{ + Size: size, + Cells: cells, + } +} + +func (b *Board) GetCell(index int) (int, error) { + if index < 0 || index >= len(b.Cells) { + return 0, fmt.Errorf("invalid cell index: %d", index) + } + + return b.Cells[index], nil +} + +func (b *Board) SetCell(index int, player int) error { + if index < 0 || index >= len(b.Cells) { + return fmt.Errorf("invalid cell index: %d", index) + } + + b.Cells[index] = player + return nil +} + +func (b *Board) Load(cells []int) error { + if len(cells) != len(b.Cells) { + return fmt.Errorf("invalid cells length: %d", len(cells)) + } + + copy(b.Cells, cells) + return nil +} + +func (b *Board) GetRowCol(index int) (int, int, error) { + if index < 0 || index >= len(b.Cells) { + return 0, 0, fmt.Errorf("invalid cell index: %d", index) + } + + return index / b.Size, index % b.Size, nil +} + +func (b *Board) ChangePerspective() { + for i := range b.Cells { + b.Cells[i] *= -1 + } +} + +func (b *Board) Copy() *Board { + newBoard := NewBoard(b.Size) + copy(newBoard.Cells, b.Cells) + return newBoard +} + +func (b *Board) Print() { + for i := 0; i < b.Size; i++ { + for j := 0; j < b.Size; j++ { + cell, _ := b.GetCell(i*b.Size + j) + if cell == P1 { + fmt.Print("O") + } else if cell == P2 { + fmt.Print("X") + } else { + fmt.Print(".") + } + } + fmt.Println() + } + fmt.Println() +} diff --git a/internal/app/tictactoe/engine/engine.go b/internal/app/tictactoe/engine/engine.go new file mode 100644 index 0000000..ef7e48f --- /dev/null +++ b/internal/app/tictactoe/engine/engine.go @@ -0,0 +1,147 @@ +package engine + +type Engine struct { + ai AI +} + +func NewEngine() *Engine { + engine := &Engine{} + mcts := NewMCTS(engine) + engine.ai = mcts + + return engine +} + +func (e *Engine) GetLegalMoves(board *Board) []int { + var moves []int + for i, cell := range board.Cells { + if cell == EMPTY { + moves = append(moves, i) + } + } + return moves +} + +func (e *Engine) PlayMove(board *Board, move int, player int) error { + return board.SetCell(move, player) +} + +func (e *Engine) GetOpponent(player int) int { + if player == P1 { + return P2 + } + return P1 +} + +func (e *Engine) CheckGameOver(board *Board, lastMove int) (bool, int) { + if lastMove == -1 { + return false, EMPTY + } + + if e.CheckWin(board, lastMove) { + return true, P1 + } + + if len(e.GetLegalMoves(board)) == 0 { + return true, EMPTY + } + + return false, EMPTY +} + +func (e *Engine) CheckWin(board *Board, lastMove int) bool { + player, err := board.GetCell(lastMove) + if err != nil { + panic(err) + } + if player == EMPTY { + return false + } + + row, col, err := board.GetRowCol(lastMove) + if err != nil { + panic(err) + } + + if e.checkRow(board, row, player) { + return true + } + + if e.checkCol(board, col, player) { + return true + } + + if e.checkDiagonal(board, player) { + return true + } + + return false +} + +func (e *Engine) checkRow(board *Board, row, player int) bool { + for i := 0; i < board.Size; i++ { + cell, err := board.GetCell(row*board.Size + i) + if err != nil { + panic(err) + } + + if cell != player { + return false + } + } + + return true +} + +func (e *Engine) checkCol(board *Board, col, player int) bool { + for i := 0; i < board.Size; i++ { + cell, err := board.GetCell(i*board.Size + col) + if err != nil { + panic(err) + } + + if cell != player { + return false + } + } + + return true +} + +func (e *Engine) checkDiagonal(board *Board, player int) bool { + sum := 0 + // Left to right + for i := 0; i < board.Size; i++ { + cell, err := board.GetCell(i*board.Size + i) + if err != nil { + panic(err) + } + + if cell == player { + sum += player + } + } + + if sum == board.Size*player { + return true + } + + sum = 0 + // Right to left + for i := 0; i < board.Size; i++ { + cell, err := board.GetCell(i*board.Size + board.Size - i - 1) + if err != nil { + panic(err) + } + + if cell == player { + sum += player + } + } + + if sum == board.Size*player { + return true + } + + return false +} diff --git a/internal/app/tictactoe/engine/engine_test.go b/internal/app/tictactoe/engine/engine_test.go new file mode 100644 index 0000000..21c848b --- /dev/null +++ b/internal/app/tictactoe/engine/engine_test.go @@ -0,0 +1,157 @@ +package engine + +import ( + "testing" +) + +var testCases = []struct { + input []int + expected int +}{ + // #0: first row + { + input: []int{1, 1, 0, -1, 0, -1, 0, 0, 0}, + expected: 2, + }, + // #1: first col + { + input: []int{1, 0, 0, 1, -1, 0, 0, -1, 0}, + expected: 6, + }, + // #2: second col + { + input: []int{0, 1, 0, 0, 1, -1, 0, 0, -1}, + expected: 7, + }, + // #3: diagonal left (\) + { + input: []int{1, -1, 0, 0, 1, -1, 0, 0, 0}, + expected: 8, + }, + // #4: diagonal right (/) + { + input: []int{0, -1, 1, 0, 1, -1, 0, 0, 0}, + expected: 6, + }, + // #5: middle row + { + input: []int{0, 0, 0, 1, 0, 1, -1, -1, 0}, + expected: 4, + }, + // #6: last row + { + input: []int{0, 0, 0, -1, -1, 0, 1, 1, 0}, + expected: 8, + }, + // #7: last col + { + input: []int{0, 0, 1, -1, 0, 1, 0, 0, 0}, + expected: 8, + }, + // #8: No move + { + input: []int{1, -1, 1, -1, -1, 1, 1, 1, -1}, + expected: -1, // Indicates no move left to win + }, +} + +func TestEngine_Solve(t *testing.T) { + BOARD_SIZE := 3 + engine := NewEngine() + + for _, tc := range testCases { + t.Run("Testing solve", func(t *testing.T) { + board := NewBoard(BOARD_SIZE) + board.Load(tc.input) + + move := engine.ai.Solve(board) + + if move != tc.expected { + t.Errorf("expected move %d, got %d", tc.expected, move) + } + }) + } +} + +func TestEngine_CheckWin(t *testing.T) { + BOARD_SIZE := 3 + board := NewBoard(BOARD_SIZE) + engine := NewEngine() + + t.Run("Empty board", func(t *testing.T) { + if engine.CheckWin(board, 0) { + t.Error("expected no win") + } + }) + + t.Run("Horizontal win", func(t *testing.T) { + board.SetCell(0, P1) + board.SetCell(1, P1) + board.SetCell(2, P1) + if !engine.CheckWin(board, 2) { + t.Error("expected win") + } + }) + + t.Run("Vertical win", func(t *testing.T) { + board = NewBoard(BOARD_SIZE) + board.SetCell(0, P1) + board.SetCell(3, P1) + board.SetCell(6, P1) + if !engine.CheckWin(board, 6) { + t.Error("expected win") + } + }) + + t.Run("Left diagonal win", func(t *testing.T) { + board = NewBoard(BOARD_SIZE) + board.SetCell(0, P1) + board.SetCell(4, P1) + board.SetCell(8, P1) + if !engine.CheckWin(board, 8) { + t.Error("expected win") + } + }) + + t.Run("Right diagonal win", func(t *testing.T) { + board = NewBoard(BOARD_SIZE) + board.SetCell(2, P1) + board.SetCell(4, P1) + board.SetCell(6, P1) + if !engine.CheckWin(board, 6) { + t.Error("expected win") + } + }) +} + +func TestEngine_GetLegalMoves(t *testing.T) { + BOARD_SIZE := 4 + board := NewBoard(BOARD_SIZE) + engine := NewEngine() + moves := []int{} + + t.Run("Empty board", func(t *testing.T) { + moves = engine.GetLegalMoves(board) + if len(moves) != BOARD_SIZE*BOARD_SIZE { + t.Errorf("expected %d moves, got %d", BOARD_SIZE*BOARD_SIZE, len(moves)) + } + }) + + t.Run("Full board", func(t *testing.T) { + for _, move := range moves { + board.SetCell(move, P1) + } + moves := engine.GetLegalMoves(board) + if len(moves) != 0 { + t.Errorf("expected 0 moves, got %d", len(moves)) + } + }) + + t.Run("One empty cell", func(t *testing.T) { + board.SetCell(0, EMPTY) + moves = engine.GetLegalMoves(board) + if len(moves) != 1 { + t.Errorf("expected 1 move, got %d", len(moves)) + } + }) +} diff --git a/internal/app/tictactoe/engine/mcts.go b/internal/app/tictactoe/engine/mcts.go new file mode 100644 index 0000000..972a6b4 --- /dev/null +++ b/internal/app/tictactoe/engine/mcts.go @@ -0,0 +1,230 @@ +package engine + +import ( + "fmt" + "math" + "math/rand/v2" +) + +const ( + C_VALUE = 1.41 + DEPTH = 100 +) + +type AI interface { + Solve(board *Board) int +} + +type mcts struct { + engine *Engine +} + +func NewMCTS(engine *Engine) AI { + return &mcts{engine} +} + +func (m *mcts) Solve(board *Board) int { + root := newNode(m.engine, board, -1, nil) + + for i := 0; i < DEPTH; i++ { + node := root + for node.isExpanded() { + child, err := node.selectChild() + if err != nil { + panic(err) + } + node = child + } + + isOver, value := m.engine.CheckGameOver(node.board, node.move) + value = m.engine.GetOpponent(value) + + if !isOver { + child, err := node.expand() + if err != nil { + } else { + value = child.simulate() + node = child + } + } + + node.backpropagate(value) + } + + visits := make([]float64, board.Size*board.Size) + dist := make([]float64, board.Size*board.Size) + sum := 0.0 + + for _, child := range root.children { + visits[child.move] = float64(child.visitCount) + sum += visits[child.move] + } + + for i, visit := range visits { + dist[i] = visit / sum + } + + bestMove := -1 + bestValue := 0.0 + + for i, value := range dist { + if value > bestValue { + bestMove = i + bestValue = value + } + } + + return bestMove +} + +// Represents a game node in mcts tree +type node struct { + engine *Engine + board *Board + move int + parent *node + children []*node + legalMoves map[int]bool + valueSum int + visitCount int +} + +// Create a new node +func newNode(engine *Engine, board *Board, move int, parent *node) *node { + legalMoves := engine.GetLegalMoves(board) + moves := make(map[int]bool, len(legalMoves)) + for _, m := range legalMoves { + moves[m] = true + } + + return &node{ + engine: engine, + board: board, + move: move, + parent: parent, + children: []*node{}, + legalMoves: moves, + valueSum: 0, + visitCount: 0, + } +} + +// Simulate all moves until game is over; +// Returns winner +func (n *node) simulate() int { + isOver, winner := n.engine.CheckGameOver(n.board, n.move) + if isOver { + return n.engine.GetOpponent(winner) + } + + board := n.board.Copy() + player := P1 + result := 0 + + for { + legalMoves := n.engine.GetLegalMoves(board) + moves := make(map[int]bool, len(legalMoves)) + for _, m := range legalMoves { + moves[m] = true + } + move, err := popRandomMove(moves) + if err != nil { + break + } + + board.SetCell(move, player) + isOver, winner = n.engine.CheckGameOver(board, move) + if isOver { + result = winner + break + } + + player = n.engine.GetOpponent(player) + } + + return result +} + +func (n *node) expand() (*node, error) { + move, err := popRandomMove(n.legalMoves) + if err != nil { + return nil, err + } + + n.legalMoves[move] = false + + board := n.board.Copy() + + // Every node considers itself as p1 + board.SetCell(move, P1) + board.ChangePerspective() + + child := newNode(n.engine, board, move, n) + n.children = append(n.children, child) + + return child, nil +} + +func (n *node) backpropagate(value int) { + n.visitCount++ + n.valueSum += value + + if n.parent != nil { + n.parent.backpropagate(value * -1) + } +} + +// Get next child with highest UCB +func (n *node) selectChild() (*node, error) { + if len(n.children) == 0 { + return nil, fmt.Errorf("No child nodes") + } + + var selected *node + var bestValue float64 = math.Inf(-1) + + for _, child := range n.children { + ucb := n.getUCB(child) + if selected == nil || ucb > bestValue { + selected = child + bestValue = ucb + } + } + + return selected, nil +} + +func popRandomMove(moves map[int]bool) (int, error) { + legalMoves := []int{} + for m, v := range moves { + if v { + legalMoves = append(legalMoves, m) + } + } + + if len(legalMoves) == 0 { + return -1, fmt.Errorf("No legal moves") + } + + index := rand.IntN(len(legalMoves)) + move := legalMoves[index] + + return move, nil +} + +func (n *node) isExpanded() bool { + allVisited := true + for _, m := range n.legalMoves { + if m { + allVisited = false + break + } + } + + return len(n.children) > 0 && allVisited +} + +func (n *node) getUCB(child *node) float64 { + q := 1 - ((float64(child.valueSum)/float64(child.visitCount))+1)/2 + return q + C_VALUE*math.Sqrt(math.Log(float64(n.visitCount))/float64(child.visitCount)) +} From 1fc0bcbb22a15ad4f47cf370f247d79b5c08b52f Mon Sep 17 00:00:00 2001 From: IndieCoderMM Date: Thu, 16 Jan 2025 23:37:39 +0630 Subject: [PATCH 2/4] feat: Add tictactoe ai model --- cmd/gg/main.go | 3 + internal/app/tictactoe/engine/board.go | 6 +- internal/app/tictactoe/engine/engine.go | 20 +- internal/app/tictactoe/engine/engine_test.go | 6 +- internal/app/tictactoe/engine/mcts.go | 77 ++++---- internal/app/tictactoe/engine/model.go | 185 +++++++++++++++++++ internal/app/tictactoe/tictactoe.go | 9 + 7 files changed, 242 insertions(+), 64 deletions(-) create mode 100644 internal/app/tictactoe/engine/model.go diff --git a/cmd/gg/main.go b/cmd/gg/main.go index 92ff624..5ac09f5 100644 --- a/cmd/gg/main.go +++ b/cmd/gg/main.go @@ -31,6 +31,7 @@ func main() { huh.NewOption("connect 4 (2 player)", "connect4"), huh.NewOption("pong (2 player)", "pong"), huh.NewOption("tictactoe (2 player)", "tictactoe"), + huh.NewOption("tictactoe (vs AI)", "tictactoe-ai"), ). Value(&game). Run() @@ -46,6 +47,8 @@ func main() { pong.Run() case "tictactoe": tictactoe.Run() + case "tictactoe-ai": + tictactoe.RunVsAi() case "dodger": dodger.Run() case "hangman": diff --git a/internal/app/tictactoe/engine/board.go b/internal/app/tictactoe/engine/board.go index 6a5e29d..a06020d 100644 --- a/internal/app/tictactoe/engine/board.go +++ b/internal/app/tictactoe/engine/board.go @@ -8,11 +8,7 @@ const ( EMPTY = 0 ) -type TictactoeBoard interface { - GetCell(row, col int) (int, error) - SetCell(row, col int, player int) error - GetRowCol(index int) (int, int) -} +type Player = int type Board struct { Size int diff --git a/internal/app/tictactoe/engine/engine.go b/internal/app/tictactoe/engine/engine.go index ef7e48f..6a4920c 100644 --- a/internal/app/tictactoe/engine/engine.go +++ b/internal/app/tictactoe/engine/engine.go @@ -4,9 +4,9 @@ type Engine struct { ai AI } -func NewEngine() *Engine { +func NewEngine(depth int) *Engine { engine := &Engine{} - mcts := NewMCTS(engine) + mcts := NewMCTS(engine, depth) engine.ai = mcts return engine @@ -22,31 +22,29 @@ func (e *Engine) GetLegalMoves(board *Board) []int { return moves } -func (e *Engine) PlayMove(board *Board, move int, player int) error { +func (e *Engine) PlayMove(board *Board, player int, move int) error { return board.SetCell(move, player) } func (e *Engine) GetOpponent(player int) int { - if player == P1 { - return P2 - } - return P1 + return -player } func (e *Engine) CheckGameOver(board *Board, lastMove int) (bool, int) { if lastMove == -1 { - return false, EMPTY + return false, 0 } if e.CheckWin(board, lastMove) { - return true, P1 + absValue := P1 * P2 * -1 + return true, absValue } if len(e.GetLegalMoves(board)) == 0 { - return true, EMPTY + return true, 0 } - return false, EMPTY + return false, 0 } func (e *Engine) CheckWin(board *Board, lastMove int) bool { diff --git a/internal/app/tictactoe/engine/engine_test.go b/internal/app/tictactoe/engine/engine_test.go index 21c848b..73e882c 100644 --- a/internal/app/tictactoe/engine/engine_test.go +++ b/internal/app/tictactoe/engine/engine_test.go @@ -57,7 +57,7 @@ var testCases = []struct { func TestEngine_Solve(t *testing.T) { BOARD_SIZE := 3 - engine := NewEngine() + engine := NewEngine(DEPTH) for _, tc := range testCases { t.Run("Testing solve", func(t *testing.T) { @@ -76,7 +76,7 @@ func TestEngine_Solve(t *testing.T) { func TestEngine_CheckWin(t *testing.T) { BOARD_SIZE := 3 board := NewBoard(BOARD_SIZE) - engine := NewEngine() + engine := NewEngine(DEPTH) t.Run("Empty board", func(t *testing.T) { if engine.CheckWin(board, 0) { @@ -127,7 +127,7 @@ func TestEngine_CheckWin(t *testing.T) { func TestEngine_GetLegalMoves(t *testing.T) { BOARD_SIZE := 4 board := NewBoard(BOARD_SIZE) - engine := NewEngine() + engine := NewEngine(DEPTH) moves := []int{} t.Run("Empty board", func(t *testing.T) { diff --git a/internal/app/tictactoe/engine/mcts.go b/internal/app/tictactoe/engine/mcts.go index 972a6b4..c120932 100644 --- a/internal/app/tictactoe/engine/mcts.go +++ b/internal/app/tictactoe/engine/mcts.go @@ -12,21 +12,34 @@ const ( ) type AI interface { + // Returns the best move for the current player Solve(board *Board) int } +type GameEngine interface { + // Returns gameover (bool) & a value if there's a winner + CheckGameOver(board *Board, lastMove int) (bool, int) + // Get all available moves + GetLegalMoves(board *Board) []int + // Get the opponent of a player + GetOpponent(player int) int + // Play a move on the board + PlayMove(board *Board, player int, move int) error +} + type mcts struct { - engine *Engine + engine GameEngine + depth int } -func NewMCTS(engine *Engine) AI { - return &mcts{engine} +func NewMCTS(engine GameEngine, depth int) AI { + return &mcts{engine, depth} } func (m *mcts) Solve(board *Board) int { root := newNode(m.engine, board, -1, nil) - for i := 0; i < DEPTH; i++ { + for i := 0; i < m.depth; i++ { node := root for node.isExpanded() { child, err := node.selectChild() @@ -77,25 +90,19 @@ func (m *mcts) Solve(board *Board) int { return bestMove } -// Represents a game node in mcts tree type node struct { - engine *Engine + engine GameEngine board *Board move int parent *node children []*node - legalMoves map[int]bool + legalMoves []int valueSum int visitCount int } -// Create a new node -func newNode(engine *Engine, board *Board, move int, parent *node) *node { +func newNode(engine GameEngine, board *Board, move int, parent *node) *node { legalMoves := engine.GetLegalMoves(board) - moves := make(map[int]bool, len(legalMoves)) - for _, m := range legalMoves { - moves[m] = true - } return &node{ engine: engine, @@ -103,7 +110,7 @@ func newNode(engine *Engine, board *Board, move int, parent *node) *node { move: move, parent: parent, children: []*node{}, - legalMoves: moves, + legalMoves: legalMoves, valueSum: 0, visitCount: 0, } @@ -122,17 +129,12 @@ func (n *node) simulate() int { result := 0 for { - legalMoves := n.engine.GetLegalMoves(board) - moves := make(map[int]bool, len(legalMoves)) - for _, m := range legalMoves { - moves[m] = true - } - move, err := popRandomMove(moves) + move, _, err := popRandomMove(n.engine.GetLegalMoves(board)) if err != nil { break } - board.SetCell(move, player) + n.engine.PlayMove(board, player, move) isOver, winner = n.engine.CheckGameOver(board, move) if isOver { result = winner @@ -146,19 +148,18 @@ func (n *node) simulate() int { } func (n *node) expand() (*node, error) { - move, err := popRandomMove(n.legalMoves) + move, rest, err := popRandomMove(n.legalMoves) if err != nil { return nil, err } - n.legalMoves[move] = false + n.legalMoves = rest board := n.board.Copy() + n.engine.PlayMove(board, P1, move) // Every node considers itself as p1 - board.SetCell(move, P1) board.ChangePerspective() - child := newNode(n.engine, board, move, n) n.children = append(n.children, child) @@ -170,7 +171,7 @@ func (n *node) backpropagate(value int) { n.valueSum += value if n.parent != nil { - n.parent.backpropagate(value * -1) + n.parent.backpropagate(n.engine.GetOpponent(value)) } } @@ -194,34 +195,20 @@ func (n *node) selectChild() (*node, error) { return selected, nil } -func popRandomMove(moves map[int]bool) (int, error) { - legalMoves := []int{} - for m, v := range moves { - if v { - legalMoves = append(legalMoves, m) - } - } - +func popRandomMove(legalMoves []int) (int, []int, error) { if len(legalMoves) == 0 { - return -1, fmt.Errorf("No legal moves") + return -1, legalMoves, fmt.Errorf("No legal moves") } index := rand.IntN(len(legalMoves)) move := legalMoves[index] + legalMoves = append(legalMoves[:index], legalMoves[index+1:]...) - return move, nil + return move, legalMoves, nil } func (n *node) isExpanded() bool { - allVisited := true - for _, m := range n.legalMoves { - if m { - allVisited = false - break - } - } - - return len(n.children) > 0 && allVisited + return len(n.children) > 0 && len(n.legalMoves) == 0 } func (n *node) getUCB(child *node) float64 { diff --git a/internal/app/tictactoe/engine/model.go b/internal/app/tictactoe/engine/model.go new file mode 100644 index 0000000..19a636b --- /dev/null +++ b/internal/app/tictactoe/engine/model.go @@ -0,0 +1,185 @@ +package engine + +import ( + "fmt" + "log" + "math/rand/v2" + "strconv" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type Game struct { + board *Board + engine *Engine + turn Player + winner Player + gameover bool + xcolor lipgloss.Style + ocolor lipgloss.Style +} + +const ( + size = 3 +) + +func GetModel() tea.Model { + board := NewBoard(size) + engine := NewEngine(100) + + return Game{ + board: board, + engine: engine, + turn: P1, + gameover: false, + xcolor: lipgloss.NewStyle().Foreground(lipgloss.Color("#ff0000")), + ocolor: lipgloss.NewStyle().Foreground(lipgloss.Color("#0000ff")), + } +} + +func (g Game) Init() tea.Cmd { + return nil +} + +func (g Game) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case gameOverMsg: + g.winner = msg.winner + g.turn = g.engine.GetOpponent(g.turn) + g.gameover = true + return g, nil + + case nextTurnMsg: + g.turn = g.engine.GetOpponent(g.turn) + return g, nil + + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + return g, tea.Quit + case "n", "N": + g.nextMatch() + + if g.turn == P2 { + return g, aiTurnCmd(&g) + } + + return g, nil + case "1", "2", "3", "4", "5", "6", "7", "8", "9": + // There shouldn't be an error, because this is only called for integers + index, _ := strconv.Atoi(msg.String()) + index -= 1 + cell, err := g.board.GetCell(index) + if err != nil { + log.Fatal(err) + } + + if cell == EMPTY { + g.engine.PlayMove(g.board, P1, index) + g.turn = g.engine.GetOpponent(g.turn) + + isover, win := g.engine.CheckGameOver(g.board, index) + + if isover { + if win > 0 { + g.winner = g.turn + } else { + g.winner = 0 + } + g.gameover = true + return g, nil + } + + if g.turn == P2 { + return g, aiTurnCmd(&g) + } + } + } + } + + return g, nil +} + +type gameOverMsg struct { + winner Player +} + +type nextTurnMsg struct { + move int +} + +// Handle AI turn +func aiTurnCmd(g *Game) tea.Cmd { + return func() tea.Msg { + rollout := g.board.Copy() + move := g.engine.ai.Solve(rollout) + + g.engine.PlayMove(g.board, P2, move) + + isover, win := g.engine.CheckGameOver(g.board, move) + if isover { + if win > 0 { + return gameOverMsg{winner: P2} + } + + return gameOverMsg{winner: 0} + } + + return nextTurnMsg{} + } +} + +func (g *Game) nextMatch() { + g.board = NewBoard(size) + g.gameover = false + g.winner = 0 + randLvl := rand.IntN(50) + 50 + g.engine = NewEngine(randLvl) +} + +func printCell(board *Board, index int) string { + cell, err := board.GetCell(index) + if err != nil { + panic(err) + } + + sign := printPlayer(cell) + + if sign == "" { + return fmt.Sprintf("%d", index+1) + } + + return sign +} + +func printPlayer(cell int) string { + if cell == P1 { + return "o" + } else if cell == P2 { + return "x" + } + + return "" +} + +func (g Game) View() string { + s := fmt.Sprintf("%s | %s | %s\n", printCell(g.board, 0), printCell(g.board, 1), printCell(g.board, 2)) + s += "---------\n" + s += fmt.Sprintf("%s | %s | %s\n", printCell(g.board, 3), printCell(g.board, 4), printCell(g.board, 5)) + s += "---------\n" + s += fmt.Sprintf("%s | %s | %s\n", printCell(g.board, 6), printCell(g.board, 7), printCell(g.board, 8)) + + if g.gameover { + if g.winner != 0 { + s += fmt.Sprintf("\n\nWinner: %s", printPlayer(g.winner)) + } else { + s += "\n\nDraw!\n" + } + s += "\n[Q]uit -- [N]ext match" + } else { + s += fmt.Sprintf("\n\n%s's turn", printPlayer(g.turn)) + } + + return s +} diff --git a/internal/app/tictactoe/tictactoe.go b/internal/app/tictactoe/tictactoe.go index 02c018d..b3a711f 100644 --- a/internal/app/tictactoe/tictactoe.go +++ b/internal/app/tictactoe/tictactoe.go @@ -4,6 +4,7 @@ import ( "fmt" "strconv" + "github.com/Kaamkiya/gg/internal/app/tictactoe/engine" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) @@ -129,3 +130,11 @@ func Run() { fmt.Printf("%c wins\n", winner) } + +func RunVsAi() { + p := tea.NewProgram(engine.GetModel()) + + if _, err := p.Run(); err != nil { + panic(err) + } +} From c085226b0c08fe5518b25cc59d62a58ffaa3264e Mon Sep 17 00:00:00 2001 From: IndieCoderMM Date: Sat, 18 Jan 2025 23:21:32 +0630 Subject: [PATCH 3/4] feat: Print pretty board --- internal/app/tictactoe/engine/model.go | 85 +++++++++++++++++++++----- 1 file changed, 69 insertions(+), 16 deletions(-) diff --git a/internal/app/tictactoe/engine/model.go b/internal/app/tictactoe/engine/model.go index 19a636b..d87ab25 100644 --- a/internal/app/tictactoe/engine/model.go +++ b/internal/app/tictactoe/engine/model.go @@ -16,8 +16,7 @@ type Game struct { turn Player winner Player gameover bool - xcolor lipgloss.Style - ocolor lipgloss.Style + colors map[string]lipgloss.Style } const ( @@ -28,13 +27,36 @@ func GetModel() tea.Model { board := NewBoard(size) engine := NewEngine(100) + defaultStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#f9f6f2")) + c := func(s string) lipgloss.Color { + return lipgloss.Color(s) + } + + const ( + yellow = "#FF9E3B" + dark = "#3C3A32" + gray = "#717C7C" + light = "#DCD7BA" + red = "#E63D3D" + green = "#98BB6C" + blue = "#7E9CD8" + ) + return Game{ board: board, engine: engine, turn: P1, + winner: 0, gameover: false, - xcolor: lipgloss.NewStyle().Foreground(lipgloss.Color("#ff0000")), - ocolor: lipgloss.NewStyle().Foreground(lipgloss.Color("#0000ff")), + colors: map[string]lipgloss.Style{ + "board": defaultStyle.Background(c(dark)), + "text": defaultStyle.Background(c(dark)).Foreground(c(light)), + "line": defaultStyle.Background(c(dark)).Foreground(c(gray)), + "p1": defaultStyle.Background(c(dark)).Foreground(c(yellow)), + "p2": defaultStyle.Background(c(dark)).Foreground(c(red)), + "hi": defaultStyle.Foreground(c(green)), + "status": defaultStyle.Foreground(c(blue)), + }, } } @@ -155,31 +177,62 @@ func printCell(board *Board, index int) string { func printPlayer(cell int) string { if cell == P1 { - return "o" + return "O" } else if cell == P2 { - return "x" + return "X" } return "" } func (g Game) View() string { - s := fmt.Sprintf("%s | %s | %s\n", printCell(g.board, 0), printCell(g.board, 1), printCell(g.board, 2)) - s += "---------\n" - s += fmt.Sprintf("%s | %s | %s\n", printCell(g.board, 3), printCell(g.board, 4), printCell(g.board, 5)) - s += "---------\n" - s += fmt.Sprintf("%s | %s | %s\n", printCell(g.board, 6), printCell(g.board, 7), printCell(g.board, 8)) + renderCell := func(index int) string { + cell, _ := g.board.GetCell(index) + var style lipgloss.Style + content := "" + + switch cell { + case P1: + style = g.colors["p1"] + content = "O" + case P2: + style = g.colors["p2"] + content = "X" + default: // Empty cell, show index + style = g.colors["text"] + content = strconv.Itoa(index + 1) + } + + return style.Render(content) + } + + board := "\n" + for i := 0; i < 3; i++ { + board += g.colors["board"].Render(" ") + board += renderCell(i * 3) + board += g.colors["line"].Render(" | ") + board += renderCell(i*3 + 1) + board += g.colors["line"].Render(" | ") + board += renderCell(i*3 + 2) + board += g.colors["board"].Render(" ") + + if i < 2 { + board += "\n" + g.colors["line"].Render("---+---+---") + "\n" + } + } + status := "" if g.gameover { if g.winner != 0 { - s += fmt.Sprintf("\n\nWinner: %s", printPlayer(g.winner)) + status += g.colors["hi"].Render("\n Winner: ") + status += g.colors["hi"].Render(printPlayer(g.winner)) } else { - s += "\n\nDraw!\n" + status += g.colors["hi"].Render("\n Draw!") } - s += "\n[Q]uit -- [N]ext match" + status += g.colors["status"].Render("\n\n[Q]uit -- [N]ext match") } else { - s += fmt.Sprintf("\n\n%s's turn", printPlayer(g.turn)) + status = g.colors["status"].Render(fmt.Sprintf("\n %s's turn", printPlayer(g.turn))) } - return s + return board + status } From 6b4d2e82ec1d8ee66a042bf959778bea5263a492 Mon Sep 17 00:00:00 2001 From: IndieCoderMM Date: Sun, 19 Jan 2025 00:06:00 +0630 Subject: [PATCH 4/4] feat: Add game state --- internal/app/tictactoe/engine/model.go | 111 ++++++++++++++++--------- 1 file changed, 70 insertions(+), 41 deletions(-) diff --git a/internal/app/tictactoe/engine/model.go b/internal/app/tictactoe/engine/model.go index d87ab25..75901f1 100644 --- a/internal/app/tictactoe/engine/model.go +++ b/internal/app/tictactoe/engine/model.go @@ -5,6 +5,7 @@ import ( "log" "math/rand/v2" "strconv" + "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -16,11 +17,21 @@ type Game struct { turn Player winner Player gameover bool + round int + scoreP1 int + scoreP2 int colors map[string]lipgloss.Style } const ( - size = 3 + size = 3 + yellow = "#FF9E3B" + dark = "#3C3A32" + gray = "#717C7C" + light = "#DCD7BA" + red = "#E63D3D" + green = "#98BB6C" + blue = "#7E9CD8" ) func GetModel() tea.Model { @@ -32,21 +43,14 @@ func GetModel() tea.Model { return lipgloss.Color(s) } - const ( - yellow = "#FF9E3B" - dark = "#3C3A32" - gray = "#717C7C" - light = "#DCD7BA" - red = "#E63D3D" - green = "#98BB6C" - blue = "#7E9CD8" - ) - return Game{ board: board, engine: engine, turn: P1, winner: 0, + round: 1, + scoreP1: 0, + scoreP2: 0, gameover: false, colors: map[string]lipgloss.Style{ "board": defaultStyle.Background(c(dark)), @@ -64,30 +68,48 @@ func (g Game) Init() tea.Cmd { return nil } +type gameOverMsg struct{ winner Player } +type nextTurnMsg struct{} +type aiTurnMsg struct{} + func (g Game) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { - case gameOverMsg: - g.winner = msg.winner + case aiTurnMsg: + time.Sleep(time.Millisecond * 200) + return g, aiMoveCmd(&g) + + case nextTurnMsg: g.turn = g.engine.GetOpponent(g.turn) - g.gameover = true + if g.turn == P2 { + return g, func() tea.Msg { + return aiTurnMsg{} + } + } return g, nil - case nextTurnMsg: + case gameOverMsg: + g.winner = msg.winner g.turn = g.engine.GetOpponent(g.turn) + g.gameover = true + if g.winner == P1 { + g.scoreP1 += 1 + } else if g.winner == P2 { + g.scoreP2 += 1 + } return g, nil case tea.KeyMsg: switch msg.String() { case "ctrl+c", "q": return g, tea.Quit + case "n", "N": g.nextMatch() - if g.turn == P2 { - return g, aiTurnCmd(&g) + return g, aiMoveCmd(&g) } - return g, nil + case "1", "2", "3", "4", "5", "6", "7", "8", "9": // There shouldn't be an error, because this is only called for integers index, _ := strconv.Atoi(msg.String()) @@ -99,22 +121,29 @@ func (g Game) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if cell == EMPTY { g.engine.PlayMove(g.board, P1, index) - g.turn = g.engine.GetOpponent(g.turn) isover, win := g.engine.CheckGameOver(g.board, index) if isover { if win > 0 { g.winner = g.turn + // Update score + if g.winner == P1 { + g.scoreP1 += 1 + } else if g.winner == P2 { + g.scoreP2 += 1 + } } else { g.winner = 0 } + g.gameover = true + g.turn = g.engine.GetOpponent(g.turn) return g, nil } - if g.turn == P2 { - return g, aiTurnCmd(&g) + return g, func() tea.Msg { + return nextTurnMsg{} } } } @@ -123,16 +152,8 @@ func (g Game) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return g, nil } -type gameOverMsg struct { - winner Player -} - -type nextTurnMsg struct { - move int -} - // Handle AI turn -func aiTurnCmd(g *Game) tea.Cmd { +func aiMoveCmd(g *Game) tea.Cmd { return func() tea.Msg { rollout := g.board.Copy() move := g.engine.ai.Solve(rollout) @@ -156,6 +177,8 @@ func (g *Game) nextMatch() { g.board = NewBoard(size) g.gameover = false g.winner = 0 + g.round += 1 + randLvl := rand.IntN(50) + 50 g.engine = NewEngine(randLvl) } @@ -205,8 +228,20 @@ func (g Game) View() string { return style.Render(content) } + winner := "\n" + if g.gameover { + winner = "" + if g.winner != 0 { + winner += g.colors["hi"].Render(" Winner: ") + winner += g.colors["hi"].Render(printPlayer(g.winner)) + winner += "\n" + } else { + winner += g.colors["hi"].Render(" Draw!") + winner += "\n" + } + } - board := "\n" + board := "" for i := 0; i < 3; i++ { board += g.colors["board"].Render(" ") board += renderCell(i * 3) @@ -221,18 +256,12 @@ func (g Game) View() string { } } - status := "" + status := g.colors["status"].Render(fmt.Sprintf("\n#%d:(W%d-L%d)", g.round, g.scoreP1, g.scoreP2)) if g.gameover { - if g.winner != 0 { - status += g.colors["hi"].Render("\n Winner: ") - status += g.colors["hi"].Render(printPlayer(g.winner)) - } else { - status += g.colors["hi"].Render("\n Draw!") - } - status += g.colors["status"].Render("\n\n[Q]uit -- [N]ext match") + status += g.colors["status"].Render("> [Q]uit - [N]ext match") } else { - status = g.colors["status"].Render(fmt.Sprintf("\n %s's turn", printPlayer(g.turn))) + status += g.colors["status"].Render(fmt.Sprintf("> %s's turn", printPlayer(g.turn))) } - return board + status + return winner + board + status }