diff --git a/constraints.py b/constraints.py index ca7e55f..a671308 100644 --- a/constraints.py +++ b/constraints.py @@ -3,7 +3,7 @@ from collections import defaultdict import dictionary -from wordle import LetterResult +from game import LetterResult class Constraints: diff --git a/dictionary.py b/dictionary.py index b557ddc..c860da7 100644 --- a/dictionary.py +++ b/dictionary.py @@ -1,5 +1,7 @@ #!/usr/bin/python +import h5py + WORDLE_LENGTH = 5 @@ -63,17 +65,26 @@ _invalid_words = set( ) -_wordle_dictionary = set() +class WordleWords: + _words = set() + + def __init__(self, filename): + if not self._words: + with open("words_alpha.txt") as word_file: + all_words = word_file.read().split() + self._words.update( + filter( + lambda w: len(w) == WORDLE_LENGTH and w not in _invalid_words, + all_words, + ) + ) + + def _initialize_db(self): + pass + + def __iter__(self): + return iter(self._words) def words(): - if not _wordle_dictionary: - with open("words_alpha.txt") as word_file: - all_words = word_file.read().split() - _wordle_dictionary.update( - filter( - lambda w: len(w) == WORDLE_LENGTH and w not in _invalid_words, all_words - ) - ) - - return _wordle_dictionary + return WordleWords(filename="dictionary.hdf5") diff --git a/game.py b/game.py new file mode 100644 index 0000000..9d64a40 --- /dev/null +++ b/game.py @@ -0,0 +1,76 @@ +#!/usr/bin/python + +import random +from enum import Enum + +import dictionary + + +class LetterResult(Enum): + """Is an individual letter in a word in the result?""" + + CORRECT = "C" + MOVE = "M" + INCORRECT = "I" + UNKNOWN = "U" + + +class InvalidWord(Exception): + """Thrown when an input word is not a valid Wordle.""" + + pass + + +def select_word(words=dictionary.words()): + return random.choice(list(words)) + + +def grade_word(target, guess, words=dictionary.words()): + # We don't check word length, since all valid words are 5 letters. + if guess not in words: + raise InvalidWord(f"{guess} is not a valid Wordle.") + + result = [LetterResult.UNKNOWN] * dictionary.WORDLE_LENGTH + + # We copy target, and remove letters if we find them. This handles duplicate letters correctly. + # For example, if the target is "pulls" and we guess "apple", only the first "p" should be a + # MOVE. The second should be INCORRECT. + remaining = list(target) + + # Find all correct results first. + for i in range(dictionary.WORDLE_LENGTH): + if target[i] == guess[i]: + result[i] = LetterResult.CORRECT + remaining.remove(guess[i]) + + for i in range(dictionary.WORDLE_LENGTH): + if result[i] == LetterResult.UNKNOWN: + if guess[i] in remaining: + # Letter is in the wrong place. + result[i] = LetterResult.MOVE + remaining.remove(guess[i]) + else: + # Letter is not in target. + result[i] = LetterResult.INCORRECT + + return result + + +def display_result(result, text): + opening = { + LetterResult.CORRECT: "\033[92m", + LetterResult.MOVE: "\033[33m", + LetterResult.INCORRECT: "\033[36m", + LetterResult.UNKNOWN: "", + } + closing = "\033[0m" + + return f"{opening[result]}{text}{closing}" + + +def success_result(result): + for r in result: + if r != LetterResult.CORRECT: + return False + + return True diff --git a/play.py b/play.py index 84e44ca..50182d3 100644 --- a/play.py +++ b/play.py @@ -1,6 +1,8 @@ #!/usr/bin/python -import wordle + +import dictionary +import game def display_instructions(): @@ -13,9 +15,9 @@ def display_instructions(): make up to six guesses to find out what it is. After each guess, you get feedback on each letter of your guess. - * {wordle.display_result(wordle.LetterResult.CORRECT, "Correct!")} - * {wordle.display_result(wordle.LetterResult.MOVE, "Correct, in a different location.")} - * {wordle.display_result(wordle.LetterResult.INCORRECT, "Not in the word.")} + * {game.display_result(game.LetterResult.CORRECT, "Correct!")} + * {game.display_result(game.LetterResult.MOVE, "Correct, in a different location.")} + * {game.display_result(game.LetterResult.INCORRECT, "Not in the word.")} """ ) @@ -24,7 +26,7 @@ def read_valid_guess(guess_number): while True: print(f"Guess {guess_number}: ", end="") guess = input() - if guess in wordle.dictionary(): + if guess in dictionary.words(): return guess print(f"{guess} is not a valid Wordle word. Please try again.") @@ -32,24 +34,24 @@ def read_valid_guess(guess_number): def display_results(guess, results): print(" ", end="") - for i in range(wordle.WORDLE_LENGTH): - print(wordle.display_result(results[i], guess[i]), end="") + for i in range(dictionary.WORDLE_LENGTH): + print(game.display_result(results[i], guess[i]), end="") print("") def play_game(): display_instructions() - target = wordle.select_word() + target = game.select_word() # To help with debugging # print(f"The target is: {target}") for guess_number in range(1, 6): guess = read_valid_guess(guess_number) - results = wordle.grade_word(target, guess) + results = game.grade_word(target, guess) - if wordle.success_result(results): + if game.success_result(results): print(f"You won! It took {guess_number} guesses.") return diff --git a/requirements.txt b/requirements.txt index a160d00..1d5a67c 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/solver.py b/solver.py new file mode 100644 index 0000000..ab1c6b7 --- /dev/null +++ b/solver.py @@ -0,0 +1,82 @@ +#!/usr/bin/python + +import string +from collections import defaultdict + +import dictionary + + +def analyze_dictionary(wordle_dictionary): + # Word Position, Letter: Count + position_matches = [ + defaultdict(int), + defaultdict(int), + defaultdict(int), + defaultdict(int), + defaultdict(int), + ] + + # Letter: Count + word_matches = defaultdict(int) + + for word in wordle_dictionary: + unique_letters = set(word) + for letter in unique_letters: + word_matches[letter] += 1 + + for index in range(len(word)): + letter = word[index] + assert letter in string.ascii_lowercase + position_matches[index][letter] += 1 + + return position_matches, word_matches + + +def score_word(position_matches, word_matches, word): + result = 0 + + # Green Score + for index in range(len(word)): + letter = word[index] + green_score = position_matches[index][letter] + result += 10 * green_score + + # Yellow Score + for letter in set(word): + yellow_score = word_matches[letter] + result += yellow_score + + return result + + +def scored_words(wordle_dictionary=dictionary.words()): + # word: score + scored_words = dict() + + position_matches, word_matches = analyze_dictionary(wordle_dictionary) + + for word in wordle_dictionary: + scored_words[word] = score_word(position_matches, word_matches, word) + + result = list(wordle_dictionary) + result.sort(key=lambda word: scored_words[word], reverse=True) + + return [(word, scored_words[word]) for word in result] + + +if __name__ == "__main__": + consts = constraints.Constraints() + consts.update_graded( + "cares", + [ + solver.LetterResult.INCORRECT, + solver.LetterResult.INCORRECT, + solver.LetterResult.INCORRECT, + solver.LetterResult.INCORRECT, + solver.LetterResult.INCORRECT, + ], + ) + + valid_words = consts.filter_words(dictionary.words()) + + print("Top: ", solver.scored_words(valid_words)[:5]) diff --git a/test_constraints.py b/test_constraints.py index 6e472e3..cdba7c2 100644 --- a/test_constraints.py +++ b/test_constraints.py @@ -3,7 +3,7 @@ import unittest import constraints -from wordle import LetterResult +from game import LetterResult class TestConstraints(unittest.TestCase): diff --git a/test_game.py b/test_game.py new file mode 100644 index 0000000..08712ad --- /dev/null +++ b/test_game.py @@ -0,0 +1,79 @@ +#!/usr/bin/python + +import unittest + +import dictionary +import game + + +class TestGame(unittest.TestCase): + test_words = ( + "aaaaa", + "aabbb", + "aaccc", + "bbbaa", + "bbbbb", + "ccaaa", + "cccaa", + "ccccc", + "abcde", + ) + + def test_select_word(self): + word = game.select_word(self.test_words) + + self.assertIn(word, self.test_words) + + word = game.select_word() + + self.assertIn(word, dictionary.words()) + + def test_grade_word_correct(self): + target = "aaaaa" + guess = "aaaaa" + + result = game.grade_word(target, guess, self.test_words) + + expected = [ + game.LetterResult.CORRECT, + game.LetterResult.CORRECT, + game.LetterResult.CORRECT, + game.LetterResult.CORRECT, + game.LetterResult.CORRECT, + ] + + self.assertEqual(expected, result) + + self.assertTrue(game.success_result(result)) + + def test_grade_word_mixed(self): + C = game.LetterResult.CORRECT + M = game.LetterResult.MOVE + I = game.LetterResult.INCORRECT + + test_cases = ( + ("aabbb", "aabbb", [C, C, C, C, C]), + ("aabbb", "aaccc", [C, C, I, I, I]), + ("bbbaa", "cccaa", [I, I, I, C, C]), + ("aabbb", "cccaa", [I, I, I, M, M]), + ("aabbb", "bbbaa", [M, M, C, M, M]), + ("aabbb", "ccaaa", [I, I, M, M, I]), + ("aabbb", "abcde", [C, M, I, I, I]), + ("aabbb", "ccccc", [I, I, I, I, I]), + ) + + for target, guess, expected in test_cases: + result = game.grade_word(target, guess, self.test_words) + + self.assertEqual(expected, result) + + def test_grade_word_invalid(self): + target = "aaaaa" + guess = "inval" + + with self.assertRaises(Exception): + game.grade_word(target, guess, self.test_words) + + +if __name__ == "__main__": + unittest.main() diff --git a/test_play.py b/test_play.py new file mode 100644 index 0000000..c68bade --- /dev/null +++ b/test_play.py @@ -0,0 +1,14 @@ +#!/usr/bin/python + +import unittest + +import play + + +class TestPlay(unittest.TestCase): + def test_noop(self): + """Currently no tests for this code.""" + + +if __name__ == "__main__": + unittest.main() diff --git a/test_solver.py b/test_solver.py new file mode 100644 index 0000000..3ec8c0c --- /dev/null +++ b/test_solver.py @@ -0,0 +1,25 @@ +#!/usr/bin/python + +import unittest + +import solver + + +class TestSolver(unittest.TestCase): + def test_scored_words(self): + """Minimal end to end test.""" + result = solver.scored_words() + + expected = [ + ("cares", 139631), + ("sanes", 139129), + ("tares", 138755), + ("bares", 138375), + ("sales", 137491), + ] + + self.assertListEqual(result[:5], expected) + + +if __name__ == "__main__": + unittest.main() diff --git a/wordle.py b/wordle.py deleted file mode 100644 index d5b0954..0000000 --- a/wordle.py +++ /dev/null @@ -1,155 +0,0 @@ -#!/usr/bin/python - -import random -import string -from collections import defaultdict -from enum import Enum - -import constraints -import dictionary - - -class LetterResult(Enum): - """Is an individual letter in a word in the result?""" - - CORRECT = "C" - MOVE = "M" - INCORRECT = "I" - UNKNOWN = "U" - - -class InvalidWord(Exception): - """Thrown when an input word is not a valid Wordle.""" - - pass - - -def select_word(): - return random.choice(list(dictionary.words())) - - -def grade_word(target, guess): - # We don't check word length, since all valid words are 5 letters. - if guess not in dictionary.words(): - raise InvalidWord(f"{guess} is not a valid Wordle.") - - result = [LetterResult.UNKNOWN] * dictionary.WORDLE_LENGTH - - # We copy target, and remove letters if we find them. This handles duplicate letters correctly. - # For example, if the target is "pulls" and we guess "apple", only the first "p" should be a - # MOVE. The second should be INCORRECT. - remaining = list(target) - - # Find all correct results first. - for i in range(dictionary.WORDLE_LENGTH): - if target[i] == guess[i]: - result[i] = LetterResult.CORRECT - remaining.remove(guess[i]) - - for i in range(dictionary.WORDLE_LENGTH): - if result[i] == LetterResult.UNKNOWN: - if guess[i] in remaining: - # Letter is in the wrong place. - result[i] = LetterResult.MOVE - remaining.remove(guess[i]) - else: - # Letter is not in target. - result[i] = LetterResult.INCORRECT - - return result - - -def display_result(result, text): - opening = { - LetterResult.CORRECT: "\033[92m", - LetterResult.MOVE: "\033[33m", - LetterResult.INCORRECT: "\033[36m", - LetterResult.UNKNOWN: "", - } - closing = "\033[0m" - - return f"{opening[result]}{text}{closing}" - - -def success_result(result): - for r in result: - if r != LetterResult.CORRECT: - return False - - return True - - -def analyze_dictionary(wordle_dictionary): - # Word Position, Letter: Count - position_matches = [ - defaultdict(int), - defaultdict(int), - defaultdict(int), - defaultdict(int), - defaultdict(int), - ] - - # Letter: Count - word_matches = defaultdict(int) - - for word in wordle_dictionary: - unique_letters = set(word) - for letter in unique_letters: - word_matches[letter] += 1 - - for index in range(len(word)): - letter = word[index] - assert letter in string.ascii_lowercase - position_matches[index][letter] += 1 - - return position_matches, word_matches - - -def score_word(position_matches, word_matches, word): - result = 0 - - # Green Score - for index in range(len(word)): - letter = word[index] - green_score = position_matches[index][letter] - result += 10 * green_score - - # Yellow Score - for letter in set(word): - yellow_score = word_matches[letter] - result += yellow_score - - return result - - -def scored_words(wordle_dictionary=dictionary.words()): - # word: score - scored_words = dict() - - position_matches, word_matches = analyze_dictionary(wordle_dictionary) - - for word in wordle_dictionary: - scored_words[word] = score_word(position_matches, word_matches, word) - - result = list(wordle_dictionary) - result.sort(key=lambda word: scored_words[word], reverse=True) - - return [(word, scored_words[word]) for word in result] - - -if __name__ == "__main__": - consts = constraints.Constraints() - consts.update_graded( - "cares", - [ - LetterResult.INCORRECT, - LetterResult.INCORRECT, - LetterResult.INCORRECT, - LetterResult.INCORRECT, - LetterResult.INCORRECT, - ], - ) - - valid_words = consts.filter_words(dictionary.words()) - - print("Top: ", scored_words(valid_words)[:5])