378 lines
11 KiB
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]) |