"""CSC111 Winter 2023 Assignment 2: Trees, Wordle, and Artificial Intelligence Module Description ================== This module contains a collection of Python classes and functions that you'll use on this assignment to represent games of Adversarial Wordle. You are responsible for reading the *docstrings* of this file to understand how to use these classes and functions, but should not modify anything in this file except: 1. Uncommenting the @check_contracts decorators (see note above the AdversarialWordle class). 2. Modifying the run_example function and main block. This file will not be submitted, and we will supply our own copy for testing purposes. Note: as is standard for CSC111, we use a leading underscore to indicate private functions, methods, and instance attributes. You don't have to worry about any of these, and in fact shouldn't use them in this assignment! Disclaimer: we didn't have time to make this file fully PythonTA-compliant. Copyright and Usage Information =============================== This file is provided solely for the personal and private use of students taking CSC111 at the University of Toronto St. George campus. All forms of distribution of this code, whether as given or with any changes, are expressly prohibited. For more information on copyright for CSC111 materials, please consult our Course Syllabus. This file is Copyright (c) 2023 Mario Badr, David Liu, and Angela Zavaleta Bernuy. """ from __future__ import annotations import copy import random from typing import Iterable, Optional import plotly.graph_objects as go from plotly.subplots import make_subplots from python_ta.contracts import check_contracts # === NOTE ABOUT USING check_contracts (PLEASE READ!) === # Because this assignment involves longer computations, we recommend commenting out @check_contracts # on the line below when running your code on the larger word sets. Doing so will speed up the running # time of the AdversarialWordle methods. However, when first testing your code on smaller examples # (which you should definitely do!), we recommend keeping @check_contracts uncommented to take # advantage of the representation invariants and preconditions we've provided you. 😊 # @check_contracts class AdversarialWordle: """A class representing the state of a game of Adversarial Wordle. Instance Attributes: - word_set: a set of the allowed words for this game - max_guesses: the maximum number of guesses the Guesser player is allowed to make in this game - word_size: the length of the words in this game - guesses: a list of the guesses made by the Guesser player - statuses: a list of the statuses returned by the Adversary player NOTE: unlike CSC110 Assignment 2, each status is represented as a tuple instead of a list. Representation Invariants: - len(self.word_set) > 0 - self.word_size >= 1 - self.max_guesses >= 1 - len(self.guesses) in {len(self.statuses), len(self.statuses) + 1} - all(len(word) == self.word_size for word in self.word_set) - all(len(guess) == self.word_size for guess in self.guesses) - all(len(status) == self.word_size for status in self.statuses) - all(_is_valid_status(status) for status in self.statuses) """ word_set: frozenset[str] # frozenset is like set, but immutable word_size: int max_guesses: int guesses: list[str] statuses: list[tuple[str, ...]] # tuple[str, ...] means "a tuple of strings" _possible_answers: frozenset[str] def __init__(self, word_set: Iterable[str], max_guesses: int) -> None: """Initialize a new Adversarial Wordle game with the given word_set and max_guesses. Preconditions: - len(word_set) > 0 - all words in word_set have the same length - max_guesses >= 1 """ if isinstance(word_set, frozenset): self.word_set = word_set else: self.word_set = frozenset(word_set) self.word_size = len(next(iter(word_set))) self.max_guesses = max_guesses self.guesses = [] self.statuses = [] self._possible_answers = self.word_set def is_guesser_turn(self) -> bool: """Return whether it is the Guesser player's turn. """ return len(self.guesses) == len(self.statuses) def record_guesser_move(self, guess: str) -> None: """Record the given guess made by the Guesser player. Preconditions: - self.is_guesser_turn() - len(guess) == self.word_size - guess in self._possible_answers """ self.guesses.append(guess) def record_adversary_move(self, status: tuple[str, ...]) -> None: """Record the given status returned by the Adversary player. Preconditions: - not self.is_guesser_turn() - len(status) == self.word_size - _is_valid_status(status) """ self.statuses.append(status) # Update self._possible_answers self._possible_answers = _find_correct_answers(self._possible_answers, self.guesses, self.statuses) def copy_and_record_guesser_move(self, guess: str) -> AdversarialWordle: """Return a copy of this game state with the given guess recorded. Preconditions: - self.is_guesser_turn() - len(guess) == self.word_size - guess in self._possible_answers """ new_game = self._copy() new_game.record_guesser_move(guess) return new_game def copy_and_record_adversary_move(self, status: tuple[str, ...]) -> AdversarialWordle: """Return a copy of this game state with the given status recorded. Preconditions: - not self.is_guesser_turn() - len(status) == self.word_size - _is_valid_status(status) """ new_game = self._copy() new_game.record_adversary_move(status) return new_game def _copy(self) -> AdversarialWordle: """Return a copy of this game state.""" new_game = AdversarialWordle(self.word_set, self.max_guesses) new_game.word_size = self.word_size new_game.guesses.extend(self.guesses) new_game.statuses.extend(self.statuses) new_game._possible_answers = self._possible_answers return new_game def get_possible_answers(self) -> list[str]: """Return the possible answers for the current game state, or [] if a player has won the game. The words returned are consistent with the guesses and statuses that have been recorded. If len(self.guesses) == len(self.statuses) + 1, the last guess is ignored, since it does not yet have a corresponding status. """ if self.get_winner() is None: return list(self._possible_answers) else: return [] def get_status_for_answer(self, answer: str) -> tuple[str, ...]: """Return the status for the most recent guess with respect to the given answer. Preconditions: - not self.is_guesser_turn() """ return _get_guess_status(answer, self.guesses[-1]) def get_winner(self) -> Optional[str]: """Return the winner of the game ('Guesser' or 'Adversary'). Return None if the game is not over. """ if len(self.guesses) != len(self.statuses): # It is the Adversary's turn; no one has won yet return None elif len(self.statuses) == 0: # No moves have been made; no one has won yet return None elif all(s == CORRECT for s in self.statuses[-1]): # The Adversary returned an "all correct" guess; Guesser has won return 'Guesser' elif len(self.statuses) == self.max_guesses: # The Guesser has no more guesses; Adversary has won return 'Adversary' else: # It is the Guesser's turn; no one has won yet return None def get_move_sequence(self) -> list[str | tuple[str, ...]]: """Return the move sequence made in this game. The returned list alternates between guesses (str) and statuses (tuple[str, ...]): [self.guesses[0], self.statuses[0], self.guesses[1], self.statuses[1], ...] """ moves_so_far = [] for i in range(0, len(self.guesses)): moves_so_far.append(self.guesses[i]) if i < len(self.statuses): # self.statuses may be 1 shorter than self.guesses moves_so_far.append(self.statuses[i]) return moves_so_far ################################################################################ # Guesser player classes ################################################################################ class Guesser: """An abstract class representing a Guesser player in Adversarial Wordle. This class can be subclassed to implement different strategies for the Guesser player. """ def make_move(self, game: AdversarialWordle) -> str: """Return a guess given the current game. Preconditions: - game.is_guesser_turn() """ raise NotImplementedError class RandomGuesser(Guesser): """A Guesser player that always picks a random word that is consistent with past guesses and statuses. """ def make_move(self, game: AdversarialWordle) -> str: """Return a guess given the current game. Randomly choose among all possible correct answers for the given game. Preconditions: - game.is_guesser_turn() """ possible_answers = game.get_possible_answers() return random.choice(list(possible_answers)) ################################################################################ # Adversary player classes ################################################################################ class Adversary: """An abstract class representing an Adversary player in Adversarial Wordle. This class can be subclassed to implement different strategies for the Adversary player. """ def make_move(self, game: AdversarialWordle) -> tuple[str, ...]: """Return a status given the current game. Preconditions: - not game.is_guesser_turn() """ raise NotImplementedError class RandomAdversary(Adversary): """An Adversary player that always picks a random answer consistent with the previous rounds. Avoids picking the most recent guess whenever possible. """ def make_move(self, game: AdversarialWordle) -> tuple[str, ...]: """Return a status given the current game. Randomly choose among all possible correct answers for the given game, EXCEPT the most recent guess (if possible---see below), and then return the status for the current guess with respect to that answer. If the most recent guess is the only possible correct answer, then the "all CORRECT" status is returned. But if there is at least one correct answer other than the most recent guess, then the guess is never selected. In other words, RandomAdversary avoids returning "all CORRECT" whenever possible. Preconditions: - not game.is_guesser_turn() """ possible_answers = game.get_possible_answers() current_guess = game.guesses[-1] # Remove the current guess from the possible answers if len(possible_answers) > 1: possible_answers.remove(current_guess) # Select a random answer and return the corresponding status answer = random.choice(possible_answers) return game.get_status_for_answer(answer) ################################################################################ # Functions for running games ################################################################################ def run_game(guesser: Guesser, adversary: Adversary, word_set_file: str, max_guesses: int) -> AdversarialWordle: """Run an Adversarial Wordle game between the two given players. Use the words in word_set_file, and use max_guesses as the maximum number of guesses. Return the AdversarialWordle instance after the game is complete. Preconditions: - word_set_file is a non-empty with one word per line - all words in word_set_file have the same length - max_guesses >= 1 """ with open(word_set_file) as f: word_set = {str.strip(line.lower()) for line in f} game = AdversarialWordle(word_set, max_guesses) while game.get_winner() is None: guess = guesser.make_move(game) game.record_guesser_move(guess) status = adversary.make_move(game) game.record_adversary_move(status) return game def run_games(num_games: int, guesser: Guesser, adversary: Adversary, word_set_file: str, max_guesses: int, print_game: bool = True, show_stats: bool = False) -> dict[str, int]: """Run num_games games of Adversary Wordle between the two given players. Use the given word_set_file and max_guesses (these parameters are the same as in run_game). Optional arguments: - print_game: print a record of each game (default: True) - show_stats: use Plotly to display statistics for the game runs (default: False) Preconditions: - num_games >= 1 - same preconditions for word_set_file and max_guesses as run_game """ stats = {'Guesser': 0, 'Adversary': 0} results = [] for i in range(0, num_games): guesser_copy = copy.copy(guesser) adversary_copy = copy.copy(adversary) game = run_game(guesser_copy, adversary_copy, word_set_file, max_guesses) winner = game.get_winner() stats[winner] += 1 results.append(winner) if print_game: print(f'Game {i} winner: {winner}. Moves: {game.get_move_sequence()}') for outcome in stats: print(f'{outcome}: {stats[outcome]}/{num_games} ({100.0 * stats[outcome] / num_games:.2f}%)') if show_stats: plot_game_statistics(results) return stats def plot_game_statistics(results: list[str]) -> None: """Plot the outcomes and win probabilities for a given list of Adversarial Wordle game results. Preconditions: - all(r in {'Guesser', 'Adversary'} for r in results) """ outcomes = [1 if result == 'Guesser' else 0 for result in results] cumulative_win_percentage = [sum(outcomes[0:i]) / i for i in range(1, len(outcomes) + 1)] rolling_win_percentage = \ [sum(outcomes[max(i - 50, 0):i]) / min(50, i) for i in range(1, len(outcomes) + 1)] fig = make_subplots(rows=2, cols=1) fig.add_trace(go.Scatter(y=outcomes, mode='markers', name='Outcome (1 = Guesser win, 0 = Adversary win)'), row=1, col=1) fig.add_trace(go.Scatter(y=cumulative_win_percentage, mode='lines', name='Guesser win percentage (cumulative)'), row=2, col=1) fig.add_trace(go.Scatter(y=rolling_win_percentage, mode='lines', name='Guesser win percentage (most recent 50 games)'), row=2, col=1) fig.update_yaxes(range=[0.0, 1.0], row=2, col=1) fig.update_layout(title='Adversary Wordle Game Results', xaxis_title='Game') fig.show() ################################################################################################### # Additional helper functions for Wordle rules (similar to CSC110 A2) # You do NOT need to access any of the functions in this section to complete this assignment. ################################################################################################### CORRECT = 'Y' WRONG_POSITION = '?' INCORRECT = 'N' ALL_STATUSES = {CORRECT, WRONG_POSITION, INCORRECT} def _is_wrong_position_char(answer: str, guess: str, i: int) -> bool: """Return whether the character status of guess[i] with respect to answer is WRONG_POSITION. Preconditions: - len(answer) == len(guess) - 0 <= i < len(answer) """ return ( guess[i] != answer[i] and any(j != i and guess[i] == answer[j] and guess[j] != answer[j] for j in range(0, len(answer))) ) def _get_character_status(answer: str, guess: str, i: int) -> str: """Return the character status of guess[i] with respect to answer. The return value is one of the three values {INCORRECT, WRONG_POSITION, CORRECT}. Preconditions: - len(answer) == len(guess) - 0 <= i < len(answer) """ if answer[i] == guess[i]: return CORRECT elif _is_wrong_position_char(answer, guess, i): return WRONG_POSITION else: return INCORRECT def _get_guess_status(answer: str, guess: str) -> tuple[str, ...]: """Return the guess status of the given guess with respect to answer. The return value is a list with the same length as guess, whose elements are all in the set {INCORRECT, WRONG_POSITION, CORRECT}. Preconditions: - answer != '' - len(answer) == len(guess) """ return tuple(_get_character_status(answer, guess, i) for i in range(0, len(guess))) def _is_correct_multiple(word: str, guesses: list[str], statuses: list[tuple[str, ...]]) -> bool: """Return whether the given word is a correct answer for the given guesses and statuses. If guesses and statuses have different lengths, ignore the leftover entries in the longer list. Preconditions: - all(len(word) == len(guess) for guess in guesses) - all(len(word) == len(status) for status in statuses) - all(_is_valid_status(status) for status in statuses) - word != '' """ return all(_get_guess_status(word, guess) == status for guess, status in zip(guesses, statuses)) def _find_correct_answers(word_set: Iterable[str], guesses: list[str], statuses: list[tuple[str, ...]]) -> frozenset[str]: """Return the words (from word_set) that are correct answer for the given guesses and statuses. If guesses and statuses have different lengths, ignore the leftover entries in the longer list. Preconditions: - all words in word_set have the same non-zero length - all(len(guesses[i]) == len(statuses[i]) for i in range(0, len(guesses))) - all(_is_valid_status(status) for status in statuses) """ return frozenset(word for word in word_set if _is_correct_multiple(word, guesses, statuses)) def _is_valid_status(status: Iterable[str]) -> bool: """Return whether s is a valid status. A valid status is a list that contains only the three statuses in ALL_STATUSES. """ return all(char_status in ALL_STATUSES for char_status in status) ############################################################################### # Main block ############################################################################### def run_example() -> None: """Run one or more example games using the official Wordle word set. """ # Example with a single game # game = run_game( # guesser=RandomGuesser(), # adversary=RandomAdversary(), # word_set_file='data/words/official_wordle.txt', # max_guesses=3 # ) # print(game.get_winner()) # print(game.get_move_sequence()) run_games( num_games=100, guesser=RandomGuesser(), adversary=RandomAdversary(), word_set_file='data/words/official_wordle.txt', max_guesses=4, print_game=True, show_stats=False # Try changing to True! ) if __name__ == '__main__': run_example()