import copy import logging import random from dataclasses import dataclass, field from enum import Enum, auto from itertools import repeat from multiprocessing import Pool 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 log = 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 } ) letters_eliminated: List[Dict[Letter, None]] = field( default_factory=lambda: [dict() for i in range(WORD_LENGTH)] ) class DictionaryProvider: def __init__(self, limit: Optional[int]=None): self.limit = limit self.dictionary = self._get_dictionary(self._word_filter(WORD_LENGTH)) if limit: self.dictionary = random.sample(self.dictionary, limit) def provide(self) -> List[Word]: return self.dictionary.copy() 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)) # hack to remove some roman numerals ) return f class WordleWordPlayable: 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 # Letter is not already excluded constraint = state.letter_constraints[ch] if constraint == WordleLetterConstraint.EXCLUDED: return False if ch in state.letters_eliminated[idx]: return False for letter, constraint in state.letter_constraints.items(): if constraint == WordleLetterConstraint.INCLUDED and letter not in word: return False return True class WordleRemainingWordsStateEvaluator: """ A class which takes a WordleState and returns a score for the state """ def __init__(self, dictionary_provider: DictionaryProvider, word_playable: WordleWordPlayable): self.dictionary = dictionary_provider.provide() self.word_playable = word_playable def evaluate(self, state: WordleState) -> int: """ Get a score for the given word state """ return -sum([1 for word in self.dictionary if self.word_playable.is_word_playable(state, word)]) @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, letters_eliminated=[ d.copy() for d in state.letters_eliminated ], ) for idx, ch in enumerate(guess): if ch in new_state.answer: new_state.letter_constraints[ch] = WordleLetterConstraint.INCLUDED else: new_state.letter_constraints[ch] = WordleLetterConstraint.EXCLUDED if new_state.answer[idx] == ch: new_state.board[idx] = ch else: new_state.letters_eliminated[idx][ch] = None 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, # TODO: parent class WordleStateEvaluator, move_processor: WordleMoveProcessor, word_playable: WordleWordPlayable, ): self.dictionary = dictionary_provider.provide() self.state_evaluator = state_evaluator self.move_processor = move_processor self.word_playable = word_playable def calculate( self, state: WordleState, evaluation_depth: int, limit: Optional[int]=None, ) -> List[Tuple[Word, int]]: guesses = [] words = [ word for word in self.dictionary if self.word_playable.is_word_playable(state, word) ] with Pool(4) as p: move_rankings = p.starmap(self.calc_guess, zip(repeat(state, len(words)), words)) move_rankings = sorted(move_rankings, key=lambda x: x[1], reverse=True) if limit: move_rankings = move_rankings[0:limit] return move_rankings def calc_guess(self, state: WordleState, guess: Word): # TODO: Don't recompute me, already determined in `calculate` words = [ word for word in self.dictionary if self.word_playable.is_word_playable(state, word) ] word_total = 0 for answer in words: temp_state = WordleState( board=state.board.copy(), letter_constraints=state.letter_constraints.copy(), answer=answer, letters_eliminated=[ d.copy() for d in state.letters_eliminated ], ) new_state = self.move_processor.process(temp_state, guess) word_total += self.state_evaluator.evaluate(temp_state) word_score = word_total / len(words) return (guess, word_score) def main(): logging.basicConfig(format='%(asctime)s %(message)s', level=logging.INFO) dictionary_provider = DictionaryProvider(limit=200) word_playable = WordleWordPlayable() state_evaluator = WordleRemainingWordsStateEvaluator(dictionary_provider, word_playable) move_processor = WordleMoveProcessor() move_calculator = WordleMoveCalculator( dictionary_provider, state_evaluator, move_processor, word_playable ) answer = random.choice(dictionary_provider.provide()) state = WordleState(board=[None] * WORD_LENGTH, answer=answer) iteration = 1 guesses = [] # type: List[str] while not all(state.board): log.info("Round: %d", iteration) log.debug("State: %s", str(state)) moves = move_calculator.calculate(state, 1) log.info("Best Moves: %s", moves[:5]) log.info("Num words remaining: %d", len(moves)) guess = moves[0][0] log.info("Guess: %s", guess) state = move_processor.process(state, guess) log.info("Board: %s", str(state.board)) guesses.append(guess) iteration += 1 print(f"Answer: {answer}") print(f"Iterations: {iteration - 1}") print(f"Guesses: {str(', '.join(guesses))}") if __name__ == "__main__": main()