CPG/TicTacToe/src/board.nim

378 lines
11 KiB
Nim

# Copyright 2023 Mattia Giambirtone & All Contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import ../util/matrix
import../util/multibyte
import std/random
randomize()
export matrix
export multibyte
type
TileKind* = enum
## A tile enumeration kind
TileEmpty = 0,
TileX,
TileO
GameStatus* = enum
## A game status enumeration
Playing,
WinX,
WinO
Draw
TicTacToeGame* = ref object
map*: Matrix[uint8]
last: TileKind
Move* = ref object
## A specific state in a
## tic-tac-toe match, with
## all of its children states
state*: Matrix[uint8] # The state of the board
# Since tic-tac-toe is a rather
# simple game, we can generate all
# possible (legal) board configurations
# and then look up the current board when
# playing against the user in order to find
# the best move to win the match
next*: array[9, Move]
depth*: int # How deep in the tree are we?
# What does this move result into?
outcome*: GameStatus
Choice* = object
# The result of our minimax
# search
move*: Move
weight*: int
var one = newMatrix[uint8](@[uint8(1), 1, 1])
let two = newMatrix[uint8](@[uint8(2), 2, 2])
proc dumpMatrix*[T](self: Matrix[T]): seq[byte] =
## Dumps the matrix to bytes
result.add(self.shape.rows.uint8)
result.add(self.shape.cols.uint8)
result.add(self.order.uint8)
for row in self:
for e in row:
result.add(cast[array[sizeof(T), byte]](e))
proc loadMatrix*[T](data: seq[byte]): Matrix[T] =
## Loads a matrix byte dump
new(result)
new(result.data)
var idx = 0
result.shape.rows = data[idx].int
inc(idx)
result.shape.cols = data[idx].int
inc(idx)
result.order = MatrixOrder(data[idx].int)
inc(idx)
result.data[] = newSeqOfCap[T](result.shape.getSize())
result.data[].setLen(result.shape.getSize())
copyMem(unsafeAddr result.data[0], unsafeAddr data[idx], result.shape.getSize())
proc dumpBytesHelper(self: Move, data: var seq[byte]) =
## Internal recursive helper for dumpBytes
data.extend(self.state.dumpMatrix())
data.add(self.depth.uint8)
data.add(self.outcome.uint8)
var j = 0
for i in 0..8:
if self.next[i].isNil():
continue
inc(j)
data.add(j.uint8)
for i in 0..8:
if self.next[i].isNil():
continue
data.add(i.uint8)
self.next[i].dumpBytesHelper(data)
proc dumpBytes*(self: Move): seq[byte] =
## Dumps the given tree to a byte stream
## that can be loaded back with loadBytes
self.dumpBytesHelper(result)
proc loadBytesHelper(data: seq[byte], idx: var int): Move =
## Internal recursive helper for loadBytes
new(result)
result.state = loadMatrix[uint8](data[idx..idx + 11])
inc(idx, 12)
result.depth = data[idx].int
inc(idx)
result.outcome = GameStatus(data[idx].int)
inc(idx)
var n = data[idx].int
inc(idx)
var i = 0
while n > 0:
dec(n)
i = data[idx].int
inc(idx)
result.next[i] = loadBytesHelper(data, idx)
proc loadBytes*(data: seq[byte]): Move =
## Loads a dumped tree
var x: int
return loadBytesHelper(data, x)
proc newTicTacToe*: TicTacToeGame =
## Creates a new TicTacToe object
new(result)
result.map = newMatrixFromSeq[uint8](@[uint8(0), 0, 0, 0, 0, 0, 0, 0, 0], (3, 3))
proc get*(self: TicTacToeGame): GameStatus =
## Returns the game status
if self.map.count(1) + self.map.count(2) < 5:
# The minimum number of required moves for
# a successful tic tac toe is 5, so we don't
# bother checking if there's less than those
# in the current board
return Playing
# Checks the rows
for _, row in self.map:
if all(row == one):
return WinX
elif all(row == two):
return WinO
# Checks the columns
for _, col in self.map.transpose():
if all(col == one):
return WinX
elif all(col == two):
return WinO
# Checks the diagonals
if all(self.map.diag() == one): # Top left diagonal
return WinX
elif all(self.map.diag() == two):
return WinO
# Top right diagonal (we flip the matrix left to right so
# that the diagonal gets shifted on the other side)
elif all(self.map.fliplr().diag() == one):
return WinX
elif all(self.map.fliplr().diag() == two):
return WinO
# No check was successful and there's no empty slots: it's a draw!
if not any(self.map == 0):
return Draw
# There are empty slots and no one won yet: we're still in game!
return Playing
proc winner*(self: TicTacToeGame): TileKind =
## Returns the tile of the winner (TileEmpty
## is returned if the game is still in progress
## or ended in a draw)
let status = self.get()
if status in [Playing, Draw]:
return TileEmpty
if status == WinX:
return TileX
return TileO
proc `$`*(self: TileKind): string =
case self:
of TileEmpty:
return "_"
of TileX:
return "X"
of TileO:
return "O"
proc `$`*(self: TicTacToeGame): string =
## Stringifies self
result &= "-----------\n"
for i, row in self.map:
result &= "| "
for j, e in row:
result &= $TileKind(e)
if j == 2:
result &= " |"
else:
result &= " "
if i < 2:
result &= "\n"
result &= "\n-----------"
proc place*(self: TicTacToeGame, tile: TileKind, x, y: int) =
## Places a tile onto the playing board
self.map[x, y] = uint8(tile)
proc asGame*(self: Matrix[uint8]): TicTacToeGame =
## Wraps a 3x3 matrix into a tris game
## object
assert self.shape == (3, 3)
new(result)
result.map = self
proc build*(data: seq[uint8]): TicTacToeGame =
## Builds a tris game from a flat
## sequence
new(result)
result.map = newMatrixFromSeq[uint8](data, (3, 3))
proc find*(tree: Move, map: Matrix[uint8]): Move =
## Returns the move object associated with this board
## state. A return value of nil indicates an illegal
## move
if all(map == tree.state):
return tree
else:
var i = 0
var idx: tuple[row, col: int]
var temp: Move
while i < 9:
idx = ind2sub(i, map.shape)
if tree.next[i].isNil() or tree.next[i].state[idx.row, idx.col] != map[idx.row, idx.col]:
inc(i)
continue
temp = tree.next[i].find(map)
if not temp.isNil():
return temp
inc(i)
return nil
proc generateMoves*(map: Matrix[uint8], turn: TileKind, depth: int = 0): Move =
## Generates the full tree of all
## possible tic tac toe moves starting
## from a given board state
new(result)
result.state = map.copy()
result.depth = depth
result.outcome = result.state.asGame().get()
if result.outcome != Playing:
# The game is over, no need to generate
# any more moves!
return
for row in 0..<map.shape.rows:
for col in 0..<map.shape.cols:
if TileKind(result.state[row, col]) == TileEmpty:
var copy = result.state.copy()
copy[row, col] = turn.uint8()
let index = row * map.shape.cols + col
new(result.next[index])
result.next[index] = generateMoves(copy, if turn == TileX: TileO else: TileX, depth + 1)
# This variant is suboptimal, but useful to build a bot that isn't
# impossible to beat. Maybe this could be the "easy" level, a "medium"
# level could only call the optimal findBest implementation once every
# 3 moves on average, while the hard mode could bring that down to 2.
# Impossible mode would of course always take the optimal path
#[
proc findBest*(tree: Move, map: Matrix[int]): Move =
## Finds the best possible move in the
## given playing field for the given
## player
var tree = tree.find(map)
block outer:
for i in 0..8:
if tree.next[i].isNil():
continue
if tree.next[i].outcome == WinX:
# We found a way to win on the next turn!
# This is is the best outcome possible, so
# we just stop
return tree.next[i]
# Here, we check if our opponent may win on the next
# move: if so, we block the hole that we think they'll
# use to win on the next turn so that they are blocked
for j in 0..8:
if tree.next[i].next[j].isNil():
continue
if tree.next[i].next[j].outcome == WinO:
var b = tree.state.copy()
b.raw[tree.next[i].next[j].location] = Self.int
result = tree.find(b)
break outer
# If we can't find a move that wins immediately, nor can
# we block our opponent from winning on the next turn, we
# just pick a random move
while result.isNil():
result = tree.next[rand(0..8)]
]#
proc findBest*(tree: Move, maximize: bool = true, skip: int = 0, turn: TileKind): Choice =
## Finds the best possible move for the given
## turn in the given playing field using minimax
## tree search. The first skip best results
## (default 0) are skipped.
if tree.outcome == Draw:
return Choice(move: tree, weight: 0)
let winner = tree.state.asGame().winner()
if winner == turn:
return Choice(move: tree, weight: 10 + tree.depth)
elif winner != TileEmpty:
# Means the other side won
return Choice(move: tree, weight: -10 + tree.depth)
var choices: seq[Choice] = @[]
for i in 0..8:
if tree.next[i].isNil():
continue
choices.add(tree.next[i].findBest(maximize=not maximize, turn=if turn == TileX: TileO else: TileX))
choices[^1].move = tree.next[i]
var best: Choice
var bestWeight: int = 100
var worst: Choice
var worstWeight: int = -100
var skip = skip
for choice in choices:
if skip > 0:
dec(skip)
continue
if maximize and choice.weight > worstWeight:
worstWeight = choice.weight
best = choice
elif not maximize and choice.weight < bestWeight:
worst = choice
bestWeight = choice.weight
if maximize:
return best
else:
return worst
proc print*(self: Move) =
## Traverses the move tree recursively
## (meant for debugging)
if self.isNil():
return
var board = self.state.asGame()
echo board
echo board.get(), "\n"
for i in 0..<9:
print(self.next[i])