Add slightly broken initial implementation
This commit is contained in:
197
src/wordle.py
Normal file
197
src/wordle.py
Normal 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()
|
||||||
Reference in New Issue
Block a user