co2114 2026.1.1__py3-none-any.whl → 2026.1.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- co2114/agent/environment.py +1 -1
- co2114/constraints/csp/__init__.py +291 -124
- co2114/constraints/csp/util.py +67 -28
- co2114/constraints/sudoku.py +81 -3
- co2114/optimisation/__init__.py +1 -1
- co2114/optimisation/adversarial.py +387 -0
- co2114/optimisation/planning.py +125 -41
- co2114/optimisation/things.py +9 -0
- {co2114-2026.1.1.dist-info → co2114-2026.1.3.dist-info}/METADATA +1 -1
- {co2114-2026.1.1.dist-info → co2114-2026.1.3.dist-info}/RECORD +13 -13
- {co2114-2026.1.1.dist-info → co2114-2026.1.3.dist-info}/WHEEL +1 -1
- co2114/optimisation/minimax.py +0 -185
- {co2114-2026.1.1.dist-info → co2114-2026.1.3.dist-info}/licenses/LICENSE +0 -0
- {co2114-2026.1.1.dist-info → co2114-2026.1.3.dist-info}/top_level.txt +0 -0
co2114/constraints/csp/util.py
CHANGED
|
@@ -2,56 +2,88 @@ from collections.abc import Iterable
|
|
|
2
2
|
from collections import deque
|
|
3
3
|
from copy import deepcopy
|
|
4
4
|
|
|
5
|
+
from typing import TypeVar
|
|
6
|
+
|
|
7
|
+
# from ..csp import Variable, CSP, Factor # avoid circular imports
|
|
8
|
+
Variable = TypeVar("Variable")
|
|
9
|
+
CSP = TypeVar("CSP")
|
|
10
|
+
Factor = TypeVar("Factor")
|
|
11
|
+
|
|
5
12
|
import numpy as np
|
|
6
13
|
|
|
7
|
-
def alldiff(*variables):
|
|
8
|
-
|
|
14
|
+
def alldiff(*variables:tuple[Variable|Iterable[Variable]]) -> bool:
|
|
15
|
+
""" All different constraint function"""
|
|
16
|
+
if len(variables) == 1: # unwrap if given as a single iterable
|
|
9
17
|
if not isinstance(variables[0], Iterable):
|
|
10
18
|
return True
|
|
11
19
|
variables = variables[0]
|
|
12
20
|
# variables = [
|
|
13
|
-
# variable.value for
|
|
21
|
+
# variable.value for variable in variable if variable.is_assigned]
|
|
14
22
|
values = [
|
|
15
23
|
variable.value for variable in variables
|
|
16
24
|
if variable.is_assigned]
|
|
17
|
-
return len(set(values)) == len(values)
|
|
25
|
+
return len(set(values)) == len(values) # all different
|
|
18
26
|
|
|
19
27
|
|
|
20
|
-
def aslist(npcol):
|
|
28
|
+
def aslist(npcol:Iterable) -> list:
|
|
29
|
+
""" Enforces list type """
|
|
21
30
|
return np.array(npcol).ravel().tolist()
|
|
22
31
|
|
|
23
32
|
|
|
24
|
-
def revise(factor, A, B):
|
|
33
|
+
def revise(factor:Factor, A:Variable, B:Variable) -> bool:
|
|
34
|
+
""" Revise method for AC-3 algorithm.
|
|
35
|
+
|
|
36
|
+
Checks every value in the domain of variable A to see if there is
|
|
37
|
+
a corresponding value in the domain of variable B that satisfies. Removes any that do not.
|
|
38
|
+
|
|
39
|
+
:param factor: The binary factor between variables A and B
|
|
40
|
+
:param A: The first variable in the binary factor
|
|
41
|
+
:param B: The other variable in the binary factor
|
|
42
|
+
"""
|
|
43
|
+
if A.is_assigned: return False # nothing to revise
|
|
44
|
+
|
|
25
45
|
is_revised = False
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
46
|
+
|
|
47
|
+
domain = deepcopy(A.domain) # copy to avoid set size change errors
|
|
48
|
+
for value in domain:
|
|
49
|
+
A.value = value # temporarily assign A
|
|
29
50
|
is_valid_B = False
|
|
30
51
|
for _value in B.domain:
|
|
31
|
-
B.value = _value
|
|
32
|
-
if factor.is_satisfied:
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
A.domain.remove(value)
|
|
52
|
+
B.value = _value # temporarily assign B
|
|
53
|
+
if factor.is_satisfied: is_valid_B = True
|
|
54
|
+
B.value = None # reset B
|
|
55
|
+
if not is_valid_B: # no valid B found for A=value
|
|
56
|
+
A.domain.remove(value) # remove value from A's domain
|
|
37
57
|
is_revised = True
|
|
38
|
-
A.value = None
|
|
58
|
+
A.value = None # reset A
|
|
39
59
|
return is_revised
|
|
40
60
|
|
|
41
61
|
|
|
42
|
-
def ac3(csp, log=False, inplace=True):
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
62
|
+
def ac3(csp:CSP, log:bool=False, inplace:bool=True) -> CSP | bool | None:
|
|
63
|
+
""" AC-3 algorithm for enforcing arc consistency on a CSP.
|
|
64
|
+
|
|
65
|
+
:param csp: The constraint satisfaction problem to enforce arc consistency on
|
|
66
|
+
:param log: If True, prints log messages during processing
|
|
67
|
+
:param inplace: If True, modifies the given CSP, otherwise returns a new CSP
|
|
68
|
+
:return: The modified CSP if inplace is False, True if inplace is True and successful
|
|
69
|
+
"""
|
|
70
|
+
if not inplace: csp = deepcopy(csp) # work on a copy if not inplace
|
|
71
|
+
|
|
72
|
+
arcs = deque(csp.arcs) # queue of arcs to consider
|
|
73
|
+
|
|
74
|
+
while len(arcs) > 0: # while there are arcs to consider
|
|
46
75
|
f, A, B = arcs.popleft() # end of queue
|
|
47
76
|
if log:
|
|
48
77
|
print(f"considering arc from {A.name} to {B.name}")
|
|
49
78
|
print(f" before: {A.name} in {A.domain}, {B.name} in {B.domain}")
|
|
50
|
-
if revise(f, A, B):
|
|
79
|
+
if revise(f, A, B): # if we revised A's domain
|
|
51
80
|
if log:
|
|
52
|
-
print(
|
|
53
|
-
|
|
54
|
-
|
|
81
|
+
print(
|
|
82
|
+
f" after: {A.name} in {A.domain}, {B.name} in {B.domain}")
|
|
83
|
+
if len(A.domain) == 0: # domain wiped out, failure
|
|
84
|
+
return False if inplace else None # no possible solution
|
|
85
|
+
|
|
86
|
+
for constraint in csp.constraints: # update related arcs
|
|
55
87
|
if log:
|
|
56
88
|
print(f" do we need to check constraint {constraint}")
|
|
57
89
|
print(f" is binary? {constraint.is_binary}")
|
|
@@ -61,11 +93,11 @@ def ac3(csp, log=False, inplace=True):
|
|
|
61
93
|
and A in constraint\
|
|
62
94
|
and B not in constraint:
|
|
63
95
|
for arc in constraint.arcs:
|
|
64
|
-
if arc[-1] is A:
|
|
65
|
-
if arc not in arcs:
|
|
96
|
+
if arc[-1] is A: # arc points to A
|
|
97
|
+
if arc not in arcs: # not already in queue
|
|
66
98
|
if log:
|
|
67
99
|
print(f" adding {arc} to arcs frontier")
|
|
68
|
-
arcs.append(arc)
|
|
100
|
+
arcs.append(arc) # add new arc to consider
|
|
69
101
|
elif log:
|
|
70
102
|
print(f" arc {arc} already in frontier")
|
|
71
103
|
elif log:
|
|
@@ -73,8 +105,15 @@ def ac3(csp, log=False, inplace=True):
|
|
|
73
105
|
return True if inplace else csp
|
|
74
106
|
|
|
75
107
|
|
|
76
|
-
def make_node_consistent(csp, inplace=True):
|
|
108
|
+
def make_node_consistent(csp, inplace=True) -> CSP | None:
|
|
109
|
+
""" Makes the CSP node consistent by enforcing unary constraints.
|
|
110
|
+
|
|
111
|
+
:param csp: The constraint satisfaction problem to make node consistent
|
|
112
|
+
:param inplace: If True, modifies the given CSP, otherwise returns a new CSP
|
|
113
|
+
:return: The modified CSP if inplace is False, otherwise None
|
|
114
|
+
"""
|
|
77
115
|
if not inplace: csp = deepcopy(csp)
|
|
116
|
+
|
|
78
117
|
for variable in csp.variables:
|
|
79
118
|
if variable.is_assigned: continue # ignore any assigned variables
|
|
80
119
|
domain = variable.domain.copy() # copy this to avoid set size change errors
|
co2114/constraints/sudoku.py
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import math
|
|
2
|
+
import numpy as np
|
|
3
|
+
from copy import deepcopy
|
|
2
4
|
|
|
3
|
-
from .csp import
|
|
4
|
-
from .csp.util import
|
|
5
|
+
from .csp import Variable, Factor, ConstraintSatisfactionProblem
|
|
6
|
+
from .csp.util import aslist
|
|
5
7
|
|
|
6
|
-
|
|
8
|
+
SudokuPuzzle = list[list[int]] # type alias for a Sudoku puzzle template
|
|
9
|
+
|
|
10
|
+
SUDOKU_TEMPLATES:dict[str, dict[str, SudokuPuzzle]] = {}
|
|
7
11
|
SUDOKU_TEMPLATES['SIMPLE'] = {
|
|
8
12
|
'0': [[0, 3, 4, 0],
|
|
9
13
|
[4, 0, 0, 2],
|
|
@@ -42,6 +46,7 @@ SUDOKU_TEMPLATES['SIMPLE'] = {
|
|
|
42
46
|
[0, 0, 0, 2],
|
|
43
47
|
[0, 3, 0, 0]]
|
|
44
48
|
}
|
|
49
|
+
|
|
45
50
|
SUDOKU_TEMPLATES['EASY'] = {
|
|
46
51
|
'0': [[3, 0, 5, 0, 0, 9, 0, 0, 2],
|
|
47
52
|
[7, 0, 0, 8, 0, 5, 1, 9, 0],
|
|
@@ -89,6 +94,7 @@ SUDOKU_SOLUTIONS['EASY'] = {
|
|
|
89
94
|
[8, 3, 4, 7, 1, 6, 9, 2, 5],
|
|
90
95
|
[2, 6, 7, 5, 9, 3, 4, 8, 1]]
|
|
91
96
|
}
|
|
97
|
+
|
|
92
98
|
SUDOKU_SOLUTIONS['HARD'] = {
|
|
93
99
|
'0': [[9, 3, 1, 8, 6, 5, 4, 2, 7],
|
|
94
100
|
[6, 7, 8, 2, 4, 3, 9, 1, 5],
|
|
@@ -111,3 +117,75 @@ SUDOKU_SOLUTIONS['HARD'] = {
|
|
|
111
117
|
}
|
|
112
118
|
|
|
113
119
|
|
|
120
|
+
|
|
121
|
+
class SudokuBase(ConstraintSatisfactionProblem):
|
|
122
|
+
""" Sudoku Base CSP class. Initialises a Sudoku problem as a constraint satisfaction problem."""
|
|
123
|
+
def __init__(self, init:SudokuPuzzle|None=None):
|
|
124
|
+
""" Initialise the Sudoku CSP from a given template. If no template is given, uses a default easy template.
|
|
125
|
+
|
|
126
|
+
:param init: A Sudoku puzzle template, represented as a 2D list of integers. 0 represents an empty cell.
|
|
127
|
+
"""
|
|
128
|
+
if not init: # default case
|
|
129
|
+
init = SUDOKU_TEMPLATES['EASY']['0']
|
|
130
|
+
|
|
131
|
+
template = self.validate_template(init)
|
|
132
|
+
|
|
133
|
+
self.create_variables(template)
|
|
134
|
+
variables = aslist(self.grid)
|
|
135
|
+
|
|
136
|
+
constraints = self.create_constraints(self.grid)
|
|
137
|
+
|
|
138
|
+
super().__init__(variables, constraints)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def validate_template(self, init:SudokuPuzzle) -> np.matrix:
|
|
142
|
+
""" Validates the given Sudoku template and returns it as a numpy matrix.
|
|
143
|
+
|
|
144
|
+
:param init: A Sudoku puzzle template, represented as a 2D list of integers. 0 represents an empty cell.
|
|
145
|
+
:return: The validated Sudoku template as a numpy matrix
|
|
146
|
+
"""
|
|
147
|
+
template = np.matrix(init)
|
|
148
|
+
n = template.shape[0]
|
|
149
|
+
assert n in [4,9], f"Sudoku template must be 4x4 or 9x9"
|
|
150
|
+
assert template.shape==(n,n), f"Sudoku template invalid: {template}"
|
|
151
|
+
|
|
152
|
+
self.n, self.m = n, (2 if n == 4 else 3)
|
|
153
|
+
return template
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def create_variables(self, template: np.matrix) -> None:
|
|
157
|
+
""" Creates a grid of Variables for the Sudoku CSP, and assigns values to any pre-filled cells in the template.
|
|
158
|
+
|
|
159
|
+
:param template: The Sudoku puzzle template as a numpy matrix
|
|
160
|
+
"""
|
|
161
|
+
domain = set(range(1,self.n+1))
|
|
162
|
+
self.grid = np.matrix([[Variable(domain, name=str((i,j)))
|
|
163
|
+
for j in range(self.n)]
|
|
164
|
+
for i in range(self.n)])
|
|
165
|
+
for i in range(self.n):
|
|
166
|
+
for j in range(self.n):
|
|
167
|
+
if template[i,j] != 0:
|
|
168
|
+
self.grid[i,j].value = template[i,j]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def __repr__(self) -> str:
|
|
172
|
+
""" Print Sudoku CSP as a grid """
|
|
173
|
+
w = int((self.n+self.m)*2+1)
|
|
174
|
+
outstr = "—"*w+"\n"
|
|
175
|
+
for i in range(self.n):
|
|
176
|
+
outstr += "| "
|
|
177
|
+
for j in range(self.n):
|
|
178
|
+
variable = self.grid[i,j]
|
|
179
|
+
outstr += str(variable.value) if variable.is_assigned else "?"
|
|
180
|
+
outstr += " " if (j+1) % self.m else " | "
|
|
181
|
+
outstr += "\n" if (i+1) % self.m else "\n"+"—"*w+"\n"
|
|
182
|
+
return outstr
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def create_constraints(self, grid: np.matrix) -> set[Factor]:
|
|
186
|
+
""" Constraints class for Sudoku CSP. Creates the constraints for the Sudoku problem. Called in the constructor.
|
|
187
|
+
|
|
188
|
+
input: grid - the grid of Variables for the Sudoku CSP
|
|
189
|
+
output: a set of Factors representing the constraints for the Sudoku problem
|
|
190
|
+
"""
|
|
191
|
+
raise NotImplementedError("You need to implement the create_constraints method for the Sudoku class")
|
co2114/optimisation/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__all__ = ["planning", "things", "
|
|
1
|
+
__all__ = ["planning", "things", "adversarial"]
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
from ..search.things import Agent
|
|
2
|
+
from ..agent.environment import XYEnvironment
|
|
3
|
+
from ..search.things import *
|
|
4
|
+
from numpy import inf, min, max
|
|
5
|
+
from typing import override, Literal, Generic, TypeVar
|
|
6
|
+
|
|
7
|
+
from copy import deepcopy
|
|
8
|
+
|
|
9
|
+
Agent = Agent
|
|
10
|
+
Player = Literal["player", "opponent", "terminal"] # player identifier type
|
|
11
|
+
State = TypeVar("State") # type alias for game state representation
|
|
12
|
+
Numeric = int | float # type alias for numerical scores
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Tile(Thing):
|
|
16
|
+
""" Utility class representing a tile on the Tic-Tac-Toe board. """
|
|
17
|
+
def __init__(self, player:str | None=None) -> None:
|
|
18
|
+
""" Constructor for Tile.
|
|
19
|
+
|
|
20
|
+
:param player: The player identifier ("X" or "O") occupying this tile, or None if empty.
|
|
21
|
+
"""
|
|
22
|
+
self.player = player
|
|
23
|
+
self.min_val, self.max_val = inf, -inf # can't remember what this is for
|
|
24
|
+
|
|
25
|
+
@override
|
|
26
|
+
def __repr__(self) -> str:
|
|
27
|
+
"""Player occupying the tile or a blank space if empty."""
|
|
28
|
+
return self.player if self.player else " "
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
Board = list[list[Tile]] # type alias for Tic-Tac-Toe board representation
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def is_terminal(board:Board, return_winner:bool=False) -> bool | str:
|
|
35
|
+
""" Checks if the given Tic-Tac-Toe board state is terminal (win or draw).
|
|
36
|
+
|
|
37
|
+
:param board: The current game board state, as list of lists of Tiles.
|
|
38
|
+
:return: True if the game is over (win or draw), False otherwise.
|
|
39
|
+
"""
|
|
40
|
+
# check rows, columns, diagonals for win
|
|
41
|
+
for i in range(3):
|
|
42
|
+
if board[i][0].player and all(board[i][j].player == board[i][0].player for j in range(3)):
|
|
43
|
+
return True if not return_winner else board[i][0].player
|
|
44
|
+
if board[0][i].player and all(board[j][i].player == board[0][i].player for j in range(3)):
|
|
45
|
+
return True if not return_winner else board[0][i].player
|
|
46
|
+
if board[0][0].player and all(board[i][i].player == board[0][0].player for i in range(3)):
|
|
47
|
+
return True if not return_winner else board[0][0].player
|
|
48
|
+
if board[0][2].player and all(board[i][2-i].player == board[0][2].player for i in range(3)):
|
|
49
|
+
return True if not return_winner else board[0][2].player
|
|
50
|
+
|
|
51
|
+
# check for draw (no empty tiles)
|
|
52
|
+
if all(board[i][j].player for i in range(3) for j in range(3)):
|
|
53
|
+
return True if not return_winner else "draw"
|
|
54
|
+
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TicTacToeGame(XYEnvironment):
|
|
59
|
+
""" Noughts and Crosses (Tic-Tac-Toe) game environment. """
|
|
60
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
61
|
+
""" Constructor for TicTacToeGame.
|
|
62
|
+
|
|
63
|
+
:param args: Positional arguments for the XYEnvironment base class.
|
|
64
|
+
:param kwargs: Keyword arguments for the XYEnvironment base class.
|
|
65
|
+
"""
|
|
66
|
+
super().__init__(*args, width=3, height=3, **kwargs)
|
|
67
|
+
self.board:Board = [[Tile() for j in range(3)] for i in range(3)]
|
|
68
|
+
self.in_play = True # game is ongoing
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@property # override
|
|
72
|
+
def is_done(self) -> bool:
|
|
73
|
+
""" Checks if the game is over. """
|
|
74
|
+
if len(self.agents) == 0: return True
|
|
75
|
+
return (not self.in_play) or is_terminal(self.board)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def make_move(self) -> None:
|
|
79
|
+
""" Makes a move on the board. """
|
|
80
|
+
raise NotImplementedError
|
|
81
|
+
|
|
82
|
+
@override
|
|
83
|
+
def step(self) -> None:
|
|
84
|
+
""" Advances the game by one step, processing the player's move.
|
|
85
|
+
|
|
86
|
+
Receives input from the user for their move, updates the board state,
|
|
87
|
+
and checks for game termination.
|
|
88
|
+
"""
|
|
89
|
+
if self.is_done: return # game over
|
|
90
|
+
|
|
91
|
+
self.make_move() # get player move
|
|
92
|
+
|
|
93
|
+
super().step() # progress agent move
|
|
94
|
+
|
|
95
|
+
if self.is_done:
|
|
96
|
+
self.show_board()
|
|
97
|
+
winner = is_terminal(self.board, return_winner=True)
|
|
98
|
+
print(f"{self}: "+("draw" if winner=="draw" else f"{winner} wins"))
|
|
99
|
+
|
|
100
|
+
def show_board(self) -> None:
|
|
101
|
+
""" Utility function to print the current board state. """
|
|
102
|
+
board = ""
|
|
103
|
+
for i in (0,1):
|
|
104
|
+
board += f"_{self.board[i][0]}_|_{self.board[i][1]}_|_{self.board[i][2]}_"
|
|
105
|
+
board += "\n"
|
|
106
|
+
board += f" {self.board[2][0]} | {self.board[2][1]} | {self.board[2][2]} "
|
|
107
|
+
print(board[:-1])
|
|
108
|
+
|
|
109
|
+
@override
|
|
110
|
+
def __repr__(self) -> str:
|
|
111
|
+
""" String representation of the current board state. """
|
|
112
|
+
return "♟️"
|
|
113
|
+
|
|
114
|
+
@override
|
|
115
|
+
def add_agent(self, agent: Agent, player:str="X") -> None:
|
|
116
|
+
if not isinstance(agent, Agent):
|
|
117
|
+
raise TypeError(f"{self}: {agent} is not an Agent")
|
|
118
|
+
self.agents.add(agent)
|
|
119
|
+
self.agent = agent
|
|
120
|
+
self.agent.player = player # default "X"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@override
|
|
124
|
+
def percept(self, agent: Agent) -> Board:
|
|
125
|
+
""" Returns the current board state as the agent's percept.
|
|
126
|
+
|
|
127
|
+
:param agent: The agent receiving the percept.
|
|
128
|
+
:return board: The current game board state, as list of lists of Tiles.
|
|
129
|
+
"""
|
|
130
|
+
return self.board
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@override
|
|
134
|
+
def execute_action(self, agent: Agent, action: tuple[str, Board]) -> None:
|
|
135
|
+
""" Executes the given action by the agent on the game state.
|
|
136
|
+
|
|
137
|
+
:param agent: The agent performing the action.
|
|
138
|
+
:param action: A tuple containing the action command and the resulting state. Actions can be: ("move", state) or ("done", state).
|
|
139
|
+
"""
|
|
140
|
+
command, state = action
|
|
141
|
+
|
|
142
|
+
match command:
|
|
143
|
+
case "move":
|
|
144
|
+
self.board = agent.move(state)
|
|
145
|
+
case "done":
|
|
146
|
+
self.in_play = False
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class InteractiveTicTacToeGame(TicTacToeGame):
|
|
150
|
+
""" Interactive Tic-Tac-Toe game environment for human players. """
|
|
151
|
+
def get_position(self) -> tuple[int,int]:
|
|
152
|
+
""" Utility function to return user-inputted board position.
|
|
153
|
+
|
|
154
|
+
:return: A tuple (i,j) representing the row and column indices of the move.
|
|
155
|
+
"""
|
|
156
|
+
self.show_board()
|
|
157
|
+
|
|
158
|
+
def _read_move_safe(prompt: str,
|
|
159
|
+
_counter:int=5,
|
|
160
|
+
bounds:tuple[int,int] = (0,2)) -> int:
|
|
161
|
+
""" Utility to read integer input safely within bounds.
|
|
162
|
+
|
|
163
|
+
:param prompt: The input prompt string.
|
|
164
|
+
:param _counter: Number of attempts before giving up.
|
|
165
|
+
:param bounds: Tuple of (min, max) valid integer bounds.
|
|
166
|
+
:return: The valid integer input from the user.
|
|
167
|
+
"""
|
|
168
|
+
try:
|
|
169
|
+
_unsafe_input = input(prompt)
|
|
170
|
+
i = int(_unsafe_input)-1
|
|
171
|
+
if i < bounds[0] or i > bounds[1]:
|
|
172
|
+
raise ValueError(f"Out of bounds, ensure it number is integer [{bounds[0]+1}-{bounds[1]+1}]")
|
|
173
|
+
except Exception as e:
|
|
174
|
+
print(e)
|
|
175
|
+
if _counter > 0:
|
|
176
|
+
return _read_move_safe(prompt, _counter-1, bounds)
|
|
177
|
+
else:
|
|
178
|
+
raise SystemError("I give up.")
|
|
179
|
+
return i
|
|
180
|
+
|
|
181
|
+
i = _read_move_safe("choose move row [1-3] ")
|
|
182
|
+
j = _read_move_safe("choose move column [1-3] ")
|
|
183
|
+
return i,j
|
|
184
|
+
|
|
185
|
+
@override
|
|
186
|
+
def make_move(self) -> None:
|
|
187
|
+
""" Makes a move on the board. """
|
|
188
|
+
i,j = self.get_position() # needs defining
|
|
189
|
+
|
|
190
|
+
placed = False # flag for valid move
|
|
191
|
+
if not self.board[i][j].player: # places mark if valid
|
|
192
|
+
self.board[i][j].player = "X" if self.agent.player == "O" else "O"
|
|
193
|
+
placed = True
|
|
194
|
+
|
|
195
|
+
while not placed:
|
|
196
|
+
print("not a valid move !")
|
|
197
|
+
i,j = self.get_position()
|
|
198
|
+
if (i < 0 or i > 2 or j < 0 or j > 2):
|
|
199
|
+
continue
|
|
200
|
+
if not self.board[i][j].player:
|
|
201
|
+
self.board[i][j].player = "X" if self.agent.player == "O" else "O"
|
|
202
|
+
placed = True
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@override
|
|
206
|
+
def execute_action(self, agent: Agent, action: tuple[str, Board]) -> None:
|
|
207
|
+
print(f"{agent}: executing action: {action}")
|
|
208
|
+
return super().execute_action(agent, action)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class AdversarialAgent(UtilityBasedAgent, Generic[State]):
|
|
212
|
+
""" Agent for adversarial search problems. """
|
|
213
|
+
def to_move(self, state:State) -> Player:
|
|
214
|
+
""" Returns which player is to move in the given state.
|
|
215
|
+
|
|
216
|
+
Receives the game state and determines which player's turn it is.
|
|
217
|
+
Must be implemented by subclasses.
|
|
218
|
+
|
|
219
|
+
:param state: The current game state.
|
|
220
|
+
:return player: The player whose turn it is to move.
|
|
221
|
+
"""
|
|
222
|
+
raise NotImplementedError
|
|
223
|
+
|
|
224
|
+
def moves(self, state:State) -> list[State]:
|
|
225
|
+
""" Returns the collection of possible moves in the given state.
|
|
226
|
+
|
|
227
|
+
Receives the game state and returns a list of possible resulting states
|
|
228
|
+
after all legal moves for the current player.
|
|
229
|
+
Must be implemented by subclasses.
|
|
230
|
+
|
|
231
|
+
:param state: The current game state.
|
|
232
|
+
:return moves: A list of possible resulting game states.
|
|
233
|
+
"""
|
|
234
|
+
raise NotImplementedError
|
|
235
|
+
|
|
236
|
+
def score(self, state:State) -> Numeric:
|
|
237
|
+
""" Returns the score for the given state.
|
|
238
|
+
|
|
239
|
+
Receives the game state and returns a numerical score indicating the
|
|
240
|
+
desirability of that state for the agent.
|
|
241
|
+
Must be implemented by subclasses.
|
|
242
|
+
|
|
243
|
+
:param state: The current game state.
|
|
244
|
+
:return score: A numerical score for the state.
|
|
245
|
+
"""
|
|
246
|
+
raise NotImplementedError
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class TicTacToeAgent[Board](AdversarialAgent):
|
|
250
|
+
def __init__(self, player:Literal["X","O"]="X") -> None:
|
|
251
|
+
""" Constructor for TicTacToeAgent.
|
|
252
|
+
|
|
253
|
+
:param player: The player identifier ("X" or "O") for this agent.
|
|
254
|
+
"""
|
|
255
|
+
super().__init__()
|
|
256
|
+
self.player = player
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@override
|
|
260
|
+
def __repr__(self) -> str:
|
|
261
|
+
""" String representation of the TicTacToeAgent.
|
|
262
|
+
:return: The player identifier if set, otherwise the default representation.
|
|
263
|
+
"""
|
|
264
|
+
if hasattr(self, "player"):
|
|
265
|
+
return self.player
|
|
266
|
+
return super().__repr__() # fallback
|
|
267
|
+
|
|
268
|
+
@override
|
|
269
|
+
def score(self, state:Board) -> int | None:
|
|
270
|
+
""" Determines the score of the given Tic-Tac-Toe state for the agent.
|
|
271
|
+
|
|
272
|
+
If not terminal, returns None.
|
|
273
|
+
If a winning game state for the agent, returns 1.
|
|
274
|
+
If a losing game state for the agent, returns -1.
|
|
275
|
+
If a draw, returns 0.
|
|
276
|
+
|
|
277
|
+
:param state: The current game state as a list of lists representing the board
|
|
278
|
+
:return score: Numerical score for the state or None if non-terminal.
|
|
279
|
+
"""
|
|
280
|
+
draw = True # check
|
|
281
|
+
index = lambda x,i,j: x[i][j]
|
|
282
|
+
|
|
283
|
+
def is_win(check:list[tuple[int,int]]) -> bool:
|
|
284
|
+
""" Checks if the given positions constitute a win for a player.
|
|
285
|
+
|
|
286
|
+
:param check: List of (i,j) tuples representing board positions to check, either row, diagonal, or column.
|
|
287
|
+
:return: True if all positions are occupied by the same player, False otherwise.
|
|
288
|
+
"""
|
|
289
|
+
if all(index(state, *idx).player == tile.player for idx in check):
|
|
290
|
+
return True
|
|
291
|
+
return False
|
|
292
|
+
|
|
293
|
+
for i in range(3):
|
|
294
|
+
for j in range(3):
|
|
295
|
+
tile = index(state,i,j)
|
|
296
|
+
if not tile.player: # is empty
|
|
297
|
+
draw = False # so cannot be a draw
|
|
298
|
+
continue # check next tile
|
|
299
|
+
check = [ # rows and columns
|
|
300
|
+
[((i_+1)%3, j) for i_ in range(i, i+2)],
|
|
301
|
+
[(i,(j_+1)%3) for j_ in range(j, j+2)]]
|
|
302
|
+
if i==j: # diagonal
|
|
303
|
+
check.append([((i_+1)%3, (i_+1)%3) for i_ in range(i, i+2)])
|
|
304
|
+
if i+j == 2: # anti-diagonal
|
|
305
|
+
check.append([((i_+1)%3, (2-(i_+1)%3)%3) for i_ in range(i, i+2)])
|
|
306
|
+
if any(is_win(idxs) for idxs in check):
|
|
307
|
+
return 1 if tile.player == self.player else -1
|
|
308
|
+
|
|
309
|
+
return 0 if draw else None
|
|
310
|
+
|
|
311
|
+
@override
|
|
312
|
+
def moves(self, state:Board) -> list[Board]:
|
|
313
|
+
""" Moves available from the given Tic-Tac-Toe state.
|
|
314
|
+
|
|
315
|
+
:param state: The current game state as a list of lists representing the board and possible moves
|
|
316
|
+
:return: A list of possible resulting game states with a legal moves.
|
|
317
|
+
"""
|
|
318
|
+
to_move = self.to_move(state)
|
|
319
|
+
|
|
320
|
+
if self.player == "X":
|
|
321
|
+
player = "X" if to_move == "player" else "O"
|
|
322
|
+
else:
|
|
323
|
+
player = "X" if to_move == "opponent" else "O"
|
|
324
|
+
possible_moves = []
|
|
325
|
+
for i in range(3):
|
|
326
|
+
for j in range(3):
|
|
327
|
+
if not state[i][j].player:
|
|
328
|
+
move = deepcopy(state)#.copy()
|
|
329
|
+
move[i][j] = Tile(player)
|
|
330
|
+
possible_moves.append(move)
|
|
331
|
+
return possible_moves
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
@override
|
|
335
|
+
def move(self, state:Board) -> Board:
|
|
336
|
+
""" Returns the state after making a move.
|
|
337
|
+
:param state: The current game state as a list of lists representing the board and possible moves
|
|
338
|
+
:return: The new game state after the move has been made.
|
|
339
|
+
"""
|
|
340
|
+
return state
|
|
341
|
+
|
|
342
|
+
@override
|
|
343
|
+
def to_move(self, state:Board) -> Player:
|
|
344
|
+
""" Determines which player's turn it is in the given state.
|
|
345
|
+
|
|
346
|
+
:param state: The current game state as a list of lists representing the board and possible moves
|
|
347
|
+
:return: The player whose turn it is to move, or "terminal" if the game is over.
|
|
348
|
+
"""
|
|
349
|
+
if self.score(state) is not None:
|
|
350
|
+
return "terminal" # game over
|
|
351
|
+
|
|
352
|
+
moves_made = {"X":0, "O":0} # count moves made by each player
|
|
353
|
+
for i in range(3):
|
|
354
|
+
for j in range(3):
|
|
355
|
+
if state[i][j].player is not None:
|
|
356
|
+
moves_made[state[i][j].player] += 1
|
|
357
|
+
|
|
358
|
+
player = self.player
|
|
359
|
+
opponent = "O" if self.player == "X" else "X"
|
|
360
|
+
|
|
361
|
+
if moves_made[player] + moves_made[opponent] == 0:
|
|
362
|
+
self.first_move = "player"
|
|
363
|
+
return "player" # take first move if available
|
|
364
|
+
|
|
365
|
+
if moves_made[player] < moves_made[opponent] \
|
|
366
|
+
and moves_made[opponent] == 1:
|
|
367
|
+
self.first_move = "opponent"
|
|
368
|
+
return "player" # take second move if available
|
|
369
|
+
|
|
370
|
+
if moves_made[player] > moves_made[opponent]:
|
|
371
|
+
return "opponent"
|
|
372
|
+
elif moves_made[player] < moves_made[opponent]:
|
|
373
|
+
return "player"
|
|
374
|
+
else:
|
|
375
|
+
return self.first_move
|
|
376
|
+
|
|
377
|
+
# match self.player:
|
|
378
|
+
# case "X":
|
|
379
|
+
# return "player" if moves_made["X"] < moves_made["O"] else "opponent"
|
|
380
|
+
# case "O":
|
|
381
|
+
# return "player" if moves_made["O"] < moves_made["X"] else "opponent"
|
|
382
|
+
|
|
383
|
+
# # @override
|
|
384
|
+
# # def program(self, percepts:list[Board]) -> tuple[str, Board]:
|
|
385
|
+
# # """ Agent program to select an move based on percepts. """
|
|
386
|
+
# # raise NotImplementedError
|
|
387
|
+
|