diff --git a/Game.py b/Game.py index e69de29..3fa63b2 100644 --- a/Game.py +++ b/Game.py @@ -0,0 +1,72 @@ +import numpy as np +from State import State + +class Game: + def __init__(self, distances: np.array): + """ + The game is a TSP. A TSP can be described as a complete graph where each node is numbered with + numbers 0, ..., n_nodes -1 and each edge (i, j) has as attribute the distance between nodes i and j. + That is the Game is fully described by providing the distance matrix between nodes. + + :param distances: np.array, distance matrix. + """ + self.distances = distances + self.n_nodes = self.distances.shape[0] + + def available_actions(self, state: State) -> list[int]: + """ + Returns the list of available action at a given state. + + :param state: State, state of the game. + :return: list, all available actions. + """ + return [a for a in range(self.n_nodes) if a not in state.visited_nodes] + + def game_over(self, state: State) -> bool: + """ + True if the state is a final state, i.e. the game is over, False otherwise. + + :param state: State, state of the game. + :return: bool, True for game over, False otherwise. + """ + return self.n_nodes == len(state.visited_nodes) + + def step(self, state: State, action: int) -> State: + """ + Gives the new state achieved by performing the passed action from state. + + :param state: State, state of the game. + :param action: int, action to be performed. Should be a feasible action. + :return: State, the new state reached. + """ + visited_nodes = state.visited_nodes + [action] + return State(action, self.distances[action, :], visited_nodes) + + def get_objective(self, state: State) -> float: + """ + Give the lenght of the tour if the state is a final state, else raise an error. + :param state: State, state of the game. + :return: float, total length of the tour. + """ + if not self.game_over(state): + raise Exception("The objective of a partial solution is trying to be computed.") + + obj = self.distances[state.current_node, 0] + for node_idx in range(self.n_nodes - 1): + obj += self.distances[state.visited_nodes[node_idx], state.visited_nodes[node_idx + 1]] + + return obj + + def score(self, state: State, opponent_objective: float) -> int: + """ + Return if the game is won or not by the player. 1 if the game is won, -1 otherwise. + + :param state: State, state of the game. + :param opponent_objective: float, lenght of the tour found by the opponent. + :return: int, 1 if the lenght of the tour of the player is less or equal to the opponent's, -1 otherwise. + """ + player_objective = self.get_objective(state) + if player_objective <= opponent_objective: + return 1 + return -1 + diff --git a/State.py b/State.py new file mode 100644 index 0000000..fc849ef --- /dev/null +++ b/State.py @@ -0,0 +1,11 @@ +import numpy as np + + +class State: + def __init__(self, current_node: int, node_distances: np.array, visited_nodes: list[int]): + self.current_node = current_node + self.node_distances = node_distances + self.visited_nodes = visited_nodes + + def __len__(self): + return self.node_distances.shape[0] + 1 diff --git a/unittest/test_game.py b/unittest/test_game.py new file mode 100644 index 0000000..610b238 --- /dev/null +++ b/unittest/test_game.py @@ -0,0 +1,110 @@ +import unittest + +import numpy as np + +from Game import Game +from State import State + +np.random.seed(42) + +n_nodes = 4 +A = np.random.rand(n_nodes, n_nodes) + +class TestAvailableActions(unittest.TestCase): + def setUp(self) -> None: + self.distances = (A.T * A) / 2 + self.game = Game(self.distances) + + def test_available_actions(self): + state = State(1, self.distances[:, 1], [0, 1]) + avail_expected = [2, 3] + avail_returned = self.game.available_actions(state) + self.assertEqual(avail_returned, avail_expected) + + def test_no_actions(self): + state = State(1, self.distances[:, 1], [0, 3, 2, 1]) + avail_expected = [] + avail_returned = self.game.available_actions(state) + self.assertEqual(avail_returned, avail_expected) + +class TestGameOver(unittest.TestCase): + def setUp(self) -> None: + self.distances = (A.T * A) / 2 + self.game = Game(self.distances) + + self.fixtures = ( + (State(1, self.distances[:, 1], [0, 1]), False), + (State(1, self.distances[:, 1], [0, 3, 2, 1]), True), + ) + + def test_fixtures(self): + for state, expected in self.fixtures: + if expected: + self.assertTrue(self.game.game_over(state)) + else: + self.assertFalse(self.game.game_over(state)) + + +class TestStep(unittest.TestCase): + def setUp(self) -> None: + self.distances = (A.T * A) / 2 + self.game = Game(self.distances) + + self.fixtures = ( + (State(1, self.distances[:, 1], [0, 1]), 2, State(2, self.distances[:, 2], [0, 1, 2])), + (State(1, self.distances[:, 1], [0, 1]), 3, State(3, self.distances[:, 3], [0, 1, 3])), + ) + + def test_fixtures(self): + for state, action, expected in self.fixtures: + new_state = self.game.step(state, action) + self.assertEqual(new_state.current_node, expected.current_node) + self.assertTrue(all(new_state.node_distances == expected.node_distances)) + self.assertEqual(new_state.visited_nodes, expected.visited_nodes) + + +class TestGetObjective(unittest.TestCase): + def setUp(self) -> None: + self.distances = np.array( + [ + [1, 2, 3, 4], + [2, 3, 4, 5], + [3, 4, 5, 6], + [4, 5, 6, 7] + ] + ) + self.game = Game(self.distances) + self.state = State(3, self.distances[:, 3], [0, 1, 2, 3]) + + def test_objective(self): + obj_returend = self.game.get_objective(self.state) + obj_expected = 16 + self.assertEqual(obj_expected, obj_returend) + + +class TestScore(unittest.TestCase): + def setUp(self) -> None: + self.distances = np.array( + [ + [1, 2, 3, 4], + [2, 3, 4, 5], + [3, 4, 5, 6], + [4, 5, 6, 7] + ] + ) + self.game = Game(self.distances) + self.state = State(3, self.distances[:, 3], [0, 1, 2, 3]) + self.fixtures = ( + (15, -1), + (16, 1), + (17, 1), + ) + + def test_fixtures(self): + for opponent_obj, score_expected in self.fixtures: + score_returend = self.game.score(self.state, opponent_obj) + self.assertEqual(score_returend, score_expected) + + +if __name__ == "__main__": + unittest.main()