diff --git a/internal/terminalgame/gameimpl/game.go b/internal/terminalgame/gameimpl/game.go index 90808ff..30ee5f8 100644 --- a/internal/terminalgame/gameimpl/game.go +++ b/internal/terminalgame/gameimpl/game.go @@ -6,9 +6,9 @@ import ( "log" "os" + "github.com/gucio321/go-clear" "github.com/gucio321/tic-tac-go/pkg/core/board/letter" - "github.com/gucio321/go-clear" "github.com/gucio321/terminalmenu/pkg/menuutils" "github.com/gucio321/tic-tac-go/pkg/game" diff --git a/internal/terminalgame/menu/menu.go b/internal/terminalgame/menu/menu.go index e9399ad..f77fc8e 100644 --- a/internal/terminalgame/menu/menu.go +++ b/internal/terminalgame/menu/menu.go @@ -8,14 +8,13 @@ import ( "strconv" "strings" - "github.com/gucio321/go-clear" - "github.com/jaytaylor/html2text" "github.com/pkg/browser" "github.com/russross/blackfriday" "github.com/gravestench/osinfo" + "github.com/gucio321/go-clear" terminalmenu "github.com/gucio321/terminalmenu/pkg" "github.com/gucio321/terminalmenu/pkg/menuutils" @@ -57,7 +56,8 @@ func New(readme []byte) *Menu { func (m *Menu) Run() { err := <-terminalmenu.Create("Tic-Tac-Go", true). MainPage("Main Menu"). - Item("PvC game", m.runPVC). + Item("PvC game (Standard Algorithm)", m.runPVCOriginal). + Item("PvC game (Min-Max Algorithm - slower)", m.runPVCMinMax). Item("PvP game", m.runPVP). Item("Demo", m.runDemo). // [Settings] @@ -81,14 +81,20 @@ func (m *Menu) runPVP() { pvp.Run() } -func (m *Menu) runPVC() { - g := gameimpl.NewTTG(m.width, m.height, m.chainLen, game.PlayerTypeHuman, game.PlayerTypePC) +func (m *Menu) runPVCOriginal() { + g := gameimpl.NewTTG(m.width, m.height, m.chainLen, game.PlayerTypeHuman, game.PlayerTypePCOriginal) + + g.Run() +} + +func (m *Menu) runPVCMinMax() { + g := gameimpl.NewTTG(m.width, m.height, m.chainLen, game.PlayerTypeHuman, game.PlayerTypePCMinMax) g.Run() } func (m *Menu) runDemo() { - demo := gameimpl.NewTTG(m.width, m.height, m.chainLen, game.PlayerTypePC, game.PlayerTypePC) + demo := gameimpl.NewTTG(m.width, m.height, m.chainLen, game.PlayerTypePCOriginal, game.PlayerTypePCOriginal) demo.Run() } diff --git a/pkg/core/board/board_test.go b/pkg/core/board/board_test.go index 3fe1576..14c9a91 100644 --- a/pkg/core/board/board_test.go +++ b/pkg/core/board/board_test.go @@ -265,6 +265,34 @@ func Test_Board_IsWinner(t *testing.T) { } } +func Test_Board_IsWinner_TrueFalse(t *testing.T) { + tests := []struct { + name string + board *Board + letter letter.Letter + expectedResult bool + }{ + {"Noone win", &Board{ + width: 3, + height: 3, + chainLen: 3, + board: []letter.Letter{ + 0, 1, 0, + 1, 0, 1, + 0, 0, 1, + }, + }, letter.LetterX, false}, + } + + for _, test := range tests { + t.Run(test.name, func(tt *testing.T) { + a := assert.New(tt) + result, _ := test.board.IsWinner(test.letter) + a.Equal(test.expectedResult, result, "unexpected result") + }) + } +} + func Test_Board_GetWinner(t *testing.T) { tests := []struct { name string diff --git a/pkg/core/pcplayer/pc_player_engine.go b/pkg/core/pcplayer/pc_player_engine.go index 1cf79c0..22b088a 100644 --- a/pkg/core/pcplayer/pc_player_engine.go +++ b/pkg/core/pcplayer/pc_player_engine.go @@ -6,6 +6,7 @@ import ( "crypto/rand" "fmt" "math/big" + "sync" "github.com/gucio321/tic-tac-go/internal/logger" @@ -14,19 +15,28 @@ import ( "github.com/gucio321/tic-tac-go/pkg/core/players/player" ) +type AlgType byte + +const ( + AlgOriginal AlgType = iota + AlgMinMax +) + var _ player.Player = &PCPlayer{} // PCPlayer is a simple-AI logic used in Tic-Tac-Go for calculating PC-player's move. type PCPlayer struct { b *board.Board pcLetter letter.Letter + algType AlgType } // NewPCPlayer creates new PCPlayer instance. -func NewPCPlayer(b *board.Board, pcLetter letter.Letter) *PCPlayer { +func NewPCPlayer(b *board.Board, pcLetter letter.Letter, algType AlgType) *PCPlayer { return &PCPlayer{ b: b, pcLetter: pcLetter, + algType: algType, } } @@ -48,7 +58,82 @@ func (p *PCPlayer) String() string { func (p PCPlayer) GetMove() (i int) { logger.Infof("Calculating Move for PC Player") - return p.getPCMove(p.b) + switch p.algType { + case AlgOriginal: + return p.getPCMove(p.b) + case AlgMinMax: + return p.minMax(p.b) + default: + panic(fmt.Sprintf("Unknown algorithm type: %v", p.algType)) + } +} + +// THis is a min-max algorithm implementation. +// This algorithm predicts all possible solutions and chooses the best one. +// After writting this I found out the followint: +// 1. This is really ineffective: It is playable on 3x3 board, but on 4x4 it +// freezes my 12-core, 16GB RAM machine. (I will try to add MaxDepth (after reaching this it will just randomize the move) +// and maybe I'll try to optimize it so that it doesn't call recursively if not needed (solution worse than current worst)) +// 2. This is a bit theoritical conclusion but: In theory of 3x3 tic-tac-toe game, the best 2nd move (if 1st player took corner) +// should be taking the center. However after looking at algorithnm's behaviour it turns out +// that taking the center will not lead to the fastest winning opportunity. Conclusion: the algorithm should be +// improved to consider "unblockable wins" and "draws" +func (p *PCPlayer) minMax(gameBoard *board.Board) (i int) { + cw, move, _ := p.mm(gameBoard, p.pcLetter, 0) + // now if can't get best move get random from possible + if !cw { + for i := 0; i < gameBoard.Width()*gameBoard.Height(); i++ { + if !gameBoard.IsIndexFree(i) { + continue + } + move = i + break + } + } + return move +} + +func (a *PCPlayer) mm(gameBoard *board.Board, l letter.Letter, currentDepth int) (couldWin bool, move int, depth int) { + //logger.Debugf("mm: call for %s (depth: %d)\n%s", l, currentDepth, gameBoard) + depth = currentDepth + wg := sync.WaitGroup{} + m := sync.Mutex{} + for i := 0; i < gameBoard.Width()*gameBoard.Height(); i++ { + if !gameBoard.IsIndexFree(i) { + continue + } + + cp := gameBoard.Copy() + cp.SetIndexState(i, l) + if winner, _ := cp.IsWinner(l); winner { + //logger.Debugf("Can win at %d (combo %v)", i, u) + return true, i, currentDepth + 1 + } + + if cp.IsBoardFull() { + //logger.Debugf("Cant win and board full") + return false, 0, currentDepth + 1 + } + + //logger.Debugf("re-running for opposite letter") + wg.Add(1) + go func() { + cpCouldWin, cpMove, cpDepth := a.mm(cp, l.Opposite(), currentDepth+1) + m.Lock() + if cpCouldWin && (cpDepth < depth || depth == currentDepth || !couldWin) { + //logger.Debugf("mm depth %d: updated best move to %d (on depth %d)", currentDepth, cpMove, cpDepth) + depth = cpDepth + move = cpMove + couldWin = true + } + m.Unlock() + wg.Done() + }() + } + + wg.Wait() + + return } //nolint:gocyclo,funlen // https://github.com/gucio321/tic-tac-go/issues/154 diff --git a/pkg/game/game.go b/pkg/game/game.go index b305eab..9247a16 100644 --- a/pkg/game/game.go +++ b/pkg/game/game.go @@ -51,15 +51,19 @@ func Create(playerXType, playerOType PlayerType) *Game { var playerX, playerO player.Player switch playerXType { - case PlayerTypePC: - playerX = pcplayer.NewPCPlayer(result.board, letter.LetterX) + case PlayerTypePCOriginal: + playerX = pcplayer.NewPCPlayer(result.board, letter.LetterX, pcplayer.AlgOriginal) + case PlayerTypePCMinMax: + playerX = pcplayer.NewPCPlayer(result.board, letter.LetterX, pcplayer.AlgMinMax) case PlayerTypeHuman: playerX = newHumanPlayer(result.getUserAction, letter.LetterX) } switch playerOType { - case PlayerTypePC: - playerO = pcplayer.NewPCPlayer(result.board, letter.LetterO) + case PlayerTypePCOriginal: + playerO = pcplayer.NewPCPlayer(result.board, letter.LetterO, pcplayer.AlgOriginal) + case PlayerTypePCMinMax: + playerO = pcplayer.NewPCPlayer(result.board, letter.LetterO, pcplayer.AlgMinMax) case PlayerTypeHuman: playerO = newHumanPlayer(result.getUserAction, letter.LetterO) } diff --git a/pkg/game/playerType.go b/pkg/game/playerType.go index fd68934..6dab9f3 100644 --- a/pkg/game/playerType.go +++ b/pkg/game/playerType.go @@ -6,5 +6,6 @@ type PlayerType byte // player types. const ( PlayerTypeHuman PlayerType = iota - PlayerTypePC + PlayerTypePCOriginal + PlayerTypePCMinMax )