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.
@@ -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
- if len(variables) == 1:
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 variabl in variable if variable.is_assigned]
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
- if A.is_assigned: return False
27
- for value in A.domain.copy():
28
- A.value = value
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
- is_valid_B = True
34
- B.value = None
35
- if not is_valid_B:
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
- if not inplace: csp = deepcopy(csp)
44
- arcs = deque(csp.arcs)
45
- while len(arcs) > 0:
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(f" after: {A.name} in {A.domain}, {B.name} in {B.domain}")
53
- if len(A.domain) == 0: return False if inplace else None
54
- for constraint in csp.constraints:
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
@@ -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
- SUDOKU_TEMPLATES = {}
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")
@@ -1 +1 @@
1
- __all__ = ["planning", "things", "minimax"]
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
+