diff --git a/src/wordle.py b/src/wordle.py new file mode 100644 index 0000000..4c2b252 --- /dev/null +++ b/src/wordle.py @@ -0,0 +1,197 @@ +import copy +import logging +import random + +from dataclasses import dataclass, field +from enum import Enum, auto +from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple +from pathlib import Path +import string + +WORD_LENGTH = 5 # TODO: Make this configurable at runtime +logger = logging.getLogger(__name__) + +Letter = chr +Position = int +Word = str +WordlePositionState = Optional[Letter] +WordleBoardState = List[WordlePositionState] + + +class WordleLetterConstraint(Enum): + UNKNOWN = auto() # No guess made with this letter yet + EXCLUDED = auto() # Letter has been guessed and is known to not be in the word + INCLUDED = auto() # Letter has been guessed and is known to be in the word + + +@dataclass +class WordleState: + board: WordleBoardState + answer: Word + letter_constraints: Dict[Letter, WordleLetterConstraint] = field( + default_factory=lambda: { + c: WordleLetterConstraint.UNKNOWN for c in string.ascii_lowercase + } + ) + + +class DictionaryProvider: + def provide(self) -> List[Word]: + return self._get_dictionary(self._word_filter(WORD_LENGTH)) + + def _get_dictionary(self, filter_fun: Callable[str, bool] = None) -> List[Word]: + l = [] + if not filter_fun: + filter_fun = lambda s: True + with open("/usr/share/dict/words") as d: + return [ + word + for word in filter(filter_fun, map(lambda w: w.strip(), d.readlines())) + ] + + def _word_filter(self, length: int): + def f(word: str): + return ( + len(word) == length + and all(map(lambda x: x in string.ascii_lowercase, word)) + and not all(map(lambda c: c in "xiv", word)) + and (word.startswith("s") or word.startswith("z")) + ) # hack to remove some roman numerals + + return f + + +class WordleStateEvaluator: + """ + A class which takes a WordleState and returns a score for the state + """ + + def __init__(self, dictionary_provider: DictionaryProvider): + self.dictionary_provider = dictionary_provider + + def evaluate(self, state: WordleState) -> int: + """ + Get a score for the given word state + """ + + # Score values are totally arbitrary. This will be improved in the future based on the remaining selections + + # Game is solved. Return a big number. + if all(state.board): + return 999999999 + + score = 0 + for constraint in state.letter_constraints.values(): + if constraint == WordleLetterConstraint.UNKNOWN: + score += 0 + elif constraint == WordleLetterConstraint.EXCLUDED: + score += 10 + elif constraint == WordleLetterConstraint.INCLUDED: + score += 100 + + for letter in state.board: + if letter: + score += 100 + return score + + +@dataclass +class WordleMoveProcessor: + def process(self, state: WordleState, guess: Word) -> WordleState: + new_state = WordleState( + board=state.board, + letter_constraints=state.letter_constraints.copy(), + answer=state.answer, + ) + + for idx, ch in enumerate(guess): + if ch in new_state.answer: + new_state.letter_constraints[ch] = WordleLetterConstraint.INCLUDED + if state.answer[idx] == ch: + new_state.board[idx] = ch + else: + new_state.letter_constraints[ch] = WordleLetterConstraint.EXCLUDED + return new_state + + +class WordleMoveCalculator: + """ + A class which calculates some number of optimal moves based upon the WordleStateEvaluator + """ + + def __init__( + self, + dictionary_provider: DictionaryProvider, + state_evaluator: WordleStateEvaluator, + move_processor: WordleMoveProcessor, + ): + self.dictionary = dictionary_provider.provide() + self.state_evaluator = state_evaluator + self.move_processor = move_processor + + def calculate( + self, state: WordleState, num_guesses: int, evaluation_depth: int + ) -> List[Tuple[Word, int]]: + guesses = [] + words = [ + word for word in self.dictionary if self._is_word_playable(state, word) + ] + + move_rankings = [] # type: List[Tuple[Word, int]] + for guess in words: + word_total = 0 + for answer in words: + temp_state = WordleState( + board=state.board.copy(), + letter_constraints=state.letter_constraints.copy(), + answer=answer, + ) + new_state = self.move_processor.process(temp_state, guess) + word_total += self.state_evaluator.evaluate(temp_state) + + word_score = word_total / len(words) + move_rankings.append((guess, word_score)) + + move_rankings = sorted(move_rankings, key=lambda x: x[1]) + return move_rankings[0:num_guesses] + + def _is_word_playable(self, state: WordleState, word: Word): + for idx, ch in enumerate(word): + # Position is not already solved + if state.board[idx] is not None and state.board[idx] != ch: + return False + constraint = state.letter_constraints[ch] + + # Letter is not already excluded + if constraint == WordleLetterConstraint.EXCLUDED: + return False + return True + + +def main(): + + logging.basicConfig() + + dictionary_provider = DictionaryProvider() + state_evaluator = WordleStateEvaluator(dictionary_provider) + move_processor = WordleMoveProcessor() + move_calculator = WordleMoveCalculator( + dictionary_provider, state_evaluator, move_processor + ) + + answer = random.choice(dictionary_provider.provide()) + state = WordleState(board=[None] * WORD_LENGTH, answer=answer) + iteration = 1 + while not all(state.board): + print("Round:", iteration) + print("Board:", str(state.board)) + move = move_calculator.calculate(state, 5, 1)[0] + guess = move[0] + print("Guess:", guess) + state = move_processor.process(state, guess) + iteration += 1 + print("Board:", str(state.board)) + + +if __name__ == "__main__": + main()