Add slightly broken initial implementation

This commit is contained in:
2022-01-30 22:35:59 -05:00
parent 6eb8ab800c
commit 7c50c06436

197
src/wordle.py Normal file
View File

@@ -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()