From 86fa4025c2c48e31569ae87b9ae13f1792f419a9 Mon Sep 17 00:00:00 2001 From: Felipe Muniz <35815542+fpmuniz@users.noreply.github.com> Date: Sat, 15 Jan 2022 10:34:41 -0300 Subject: [PATCH] Add complements to try (unsuccessfully) to solve the game faster --- README.md | 75 ++++++++++++++--- lib/word_stats.ex | 18 ++-- lib/wordle.ex | 163 ++++++------------------------------ lib/wordle/game.ex | 40 +++++++++ lib/wordle/solver.ex | 65 ++++++++++++++ test/integration_test.exs | 28 +++---- test/word_stats_test.exs | 8 +- test/wordle/game_test.exs | 21 +++++ test/wordle/solver_test.exs | 26 ++++++ test/wordle_test.exs | 41 ++++----- 10 files changed, 280 insertions(+), 205 deletions(-) create mode 100644 lib/wordle/game.ex create mode 100644 lib/wordle/solver.ex create mode 100644 test/wordle/game_test.exs create mode 100644 test/wordle/solver_test.exs diff --git a/README.md b/README.md index d5d3501..b75798d 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,72 @@ # WordleSolver -**TODO: Add description** +This is a very simple, CLI-based program to solve password/word games of the likes of +[Wordle](https://www.powerlanguage.co.uk/wordle/). You will need to download a dictionary of words +for the language you want to play it with. For portuguese/br, you can find a great one [right here] +(https://www.ime.usp.br/~pf/dicios/index.html). I didn't include it in the project because even +though it is generated under a GPL licence, it has probably taken IME a lot of effort in order to +create it and they should be given credit where credit is due. -## Installation +This software works in a very simple manner. Given a list of words, you can choose what you want +to do with it: you can convert all words to lowercase, filter valid/invalid words using regex, trim +whitespaces, convert diacritics into base ascii letters, etc. Currently, only Portuguese and English +languages processing are supported, but feel free to add more parsers under +`language/your_language.ex`. -If [available in Hex](https://hex.pm/docs/publish), the package can be installed -by adding `wordle_solver` to your list of dependencies in `mix.exs`: +You will need Elixir/Erlang installed. To run the program, simply type into command line: + +```bash +iex -S mix +``` + +In order to start using, let's say you want to play a game in PT-BR. If you donwloaded your +dictionary into `dicts/pt_br.txt`, you can start a simple Wordle game by running: + +```elixir +iex> words = "dicts/pt_br.txt" +iex> |> Parser.import_dictionary() # reads a dict file and converts it into a list of strings +iex> |> Parser.trim() # trims all trailing whitespace +iex> |> Language.normalize(:pt_br) # removes accents and diacritics +iex> |> Parser.filter_number_of_letters(5) # drops words with 6+ or 4- letters +iex> |> Parser.filter_valid() # removes words that aren't exclusively lowcase a-z +iex> |> WordStats.order_by_scores() # calculates each word's score based on how many good letters it has and then sorts in desc score order +["rosea", "serao", "roias", "roais", "raios", "orais", "raies", "areis", + "aires", "seria", "sarei", "reais", "eiras", "meiao", "moera", "aremo", + "remoa", "aureo", "ecoai", "ecoar", "ateio", "terao", "rotea", "reato", + "lerao", "ecoas", "coesa", "secao", "acoes", "escoa", "aceso", "estao", + "tesao", "onera", "aloes", "leoas", "lesao", "odeia", "eroda", "rodea", + "adore", "doera", "maior", "irmao", "roiam", "mario", "morai", "riamo", + "raiou", "apeio", ...] +``` + +The first element from the list is always the best guess this program has for the current word. +Let's say you input that suggestion `ROSEA` and you get: green, yellow, black, black, +black. You should translate green to 2, yellow to 1 and black (or red) to 0. Our feedback is, +therefore, "21000", meaning that R is in the right place, O is correct but in the wrong place, and +S, E, A are not found in our word. Let's input this feedback: + +```elixir +iex> Wordle.feedback(words, "21000") +["ruimo", "rimou", "ritmo", "ruido", "ruivo", "rifou", "rindo", + "rirmo", "rublo", "rigor", "rumor", "rumou", "rugou", "rufou", "rubro"] +``` + +Again, `ruimo` is the best guess. Let's say, however, you don't like that suggestion very much, and +you think `ruido` is a better guess. If you try that word in the game and you get a feedback like +"22202", you pass the word of choice as third input to `Wordle.feedback/3`: + +```elixir +iex> |> Wordle.feedback("ruido", "22202") +["ruimo", "ruivo"] +``` +Now, there are only 2 possible words. Let's say `ruivo` is the right one: ```elixir -def deps do - [ - {:wordle_solver, "~> 0.1.0"} - ] -end +iex> |> Wordle.feedback("22222", "ruivo") +["ruivo"] ``` -Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) -and published on [HexDocs](https://hexdocs.pm). Once published, the docs can -be found at . +And that's it! +You can also invoke `Wordle.solve/2` to check how many attempts it takes for the software to find +your chosen word. diff --git a/lib/word_stats.ex b/lib/word_stats.ex index b40046e..fb2f117 100644 --- a/lib/word_stats.ex +++ b/lib/word_stats.ex @@ -6,30 +6,28 @@ defmodule WordStats do iex> words = ["hi", "hello"] iex> frequencies = WordStats.letter_frequencies(words) - %{ - "e" => 0.14285714285714285, - "h" => 0.2857142857142857, - "i" => 0.14285714285714285, - "l" => 0.2857142857142857, - "o" => 0.14285714285714285 - } + %{"e" => 1, "h" => 2, "i" => 1, "l" => 2, "o" => 1} iex> WordStats.order_by_scores(words, frequencies) ["hello", "hi"] """ @spec letter_frequencies([binary]) :: map def letter_frequencies(words) do - total = Enum.reduce(words, 0, fn word, acc -> acc + String.length(word) end) - words |> Enum.map(&get_letter_count/1) |> Enum.reduce(%{}, fn counts, acc -> Map.merge(acc, counts, fn _key, count1, count2 -> count1 + count2 end) end) - |> Enum.map(fn {letter, count} -> {letter, count / total} end) + |> Enum.map(fn {letter, count} -> {letter, count} end) |> Map.new() end + @spec order_by_scores([binary]) :: [binary] + def order_by_scores(words) do + scores = letter_frequencies(words) + order_by_scores(words, scores) + end + @spec order_by_scores([binary], map()) :: [binary] def order_by_scores(words, letter_frequencies) do words diff --git a/lib/wordle.ex b/lib/wordle.ex index f673350..a419973 100644 --- a/lib/wordle.ex +++ b/lib/wordle.ex @@ -1,159 +1,46 @@ defmodule Wordle do @moduledoc ~S""" - Basic state of an wordle game. The struct in this file retains information about the game, + Basic state of an solver game. The struct in this file retains information about the game, including the sorted word list, according to how likely it is to be the right word; all words that have been suggested so far; the scores each letter has; and the name of the file that was used as a dictionary. Usage: - iex> wordle = "dicts/test.txt" - iex> |> Parser.import_dictionary() - iex> |> Parser.trim() - iex> |> Language.normalize(:en) - iex> |> Parser.filter_number_of_letters(5) - iex> |> Parser.filter_valid() - iex> |> Wordle.new() - %Wordle{ - scores: %{ - "c" => 0.13333333333333333, - "d" => 0.06666666666666667, - "e" => 0.13333333333333333, - "f" => 0.06666666666666667, - "l" => 0.06666666666666667, - "r" => 0.06666666666666667, - "s" => 0.13333333333333333, - "t" => 0.06666666666666667, - "u" => 0.06666666666666667, - "y" => 0.06666666666666667, - "a" => 0.13333333333333333 - }, - suggestions: ["faces"], - words: ["faces", "clear", "study"] - } - iex> wordle - iex> |> Wordle.solve("study") - { - :ok, - %Wordle{ - scores: %{ - "a" => 0.13333333333333333, - "c" => 0.13333333333333333, - "d" => 0.06666666666666667, - "e" => 0.13333333333333333, - "f" => 0.06666666666666667, - "l" => 0.06666666666666667, - "r" => 0.06666666666666667, - "s" => 0.13333333333333333, - "t" => 0.06666666666666667, - "u" => 0.06666666666666667, - "y" => 0.06666666666666667 - }, - suggestions: ["study", "faces"], - words: ["study"] - } - } + iex> words = ~w(done cant wont play stay opus) + iex> Wordle.solve(words, "play") + {:ok, ["play", "done"]} """ - @type t :: %Wordle{ - words: list(binary), - scores: map, - suggestions: [binary] - } + alias Wordle.{Game, Solver} - defstruct [:words, suggestions: [], scores: %{}] - - defmodule UnsolvableError do - defexception [:message] - end - - @spec new([binary]) :: t - def new(word_list) when is_list(word_list), - do: %Wordle{words: word_list} |> calculate_scores() |> suggest() - - @spec feedback(Wordle.t(), binary) :: Wordle.t() - def feedback(wordle = %Wordle{suggestions: [best_guess | _]}, feedback) do - feedback - |> String.codepoints() - |> Enum.with_index() - |> Enum.reduce(wordle, fn {letter_feedback, pos}, acc -> - letter = String.at(best_guess, pos) - - case letter_feedback do - "0" -> wrong_letter(acc, letter) - "1" -> wrong_position(acc, letter, pos) - "2" -> right_position(acc, letter, pos) - end - end) - |> suggest() - end - - @spec wrong_letter(Wordle.t(), binary) :: Wordle.t() - def wrong_letter(wordle, letter) do - words = Enum.reject(wordle.words, &String.contains?(&1, letter)) - - %Wordle{wordle | words: words} - end - - @spec wrong_position(Wordle.t(), binary, integer) :: Wordle.t() - def wrong_position(wordle, letter, position) do - words = - wordle.words - |> Enum.filter(&String.contains?(&1, letter)) - |> Enum.reject(&(String.at(&1, position) == letter)) - - %Wordle{wordle | words: words} + @spec solve([binary], binary) :: {:ok | :error, [binary]} + def solve(wordlist, right_word) do + solve(wordlist, right_word, [], wordlist) end - @spec right_position(Wordle.t(), binary, integer) :: Wordle.t() - def right_position(wordle, letter, position) do - words = Enum.filter(wordle.words, &(String.at(&1, position) == letter)) - - %Wordle{wordle | words: words} - end - - @spec solve(Wordle.t(), binary) :: {:ok | :error, Wordle.t()} - def solve(wordle = %Wordle{suggestions: [suggestion | _tl]}, suggestion), do: {:ok, wordle} - def solve(wordle = %Wordle{words: []}, _right_word), do: {:error, wordle} - - def solve(wordle, right_word) do - feedback = build_feedback(wordle, right_word) + @spec solve([binary], binary, [binary], [binary]) :: {:ok | :error, [binary]} + def solve(wordlist, right_word, guesses, complement) - wordle - |> feedback(feedback) - |> solve(right_word) - end + def solve([], _right_word, guesses, _complement), do: {:error, guesses} + def solve([best_guess | _], best_guess, guesses, _complement), do: {:ok, [best_guess | guesses]} - @spec calculate_scores(Wordle.t()) :: Wordle.t() - defp calculate_scores(wordle) do - scores = WordStats.letter_frequencies(wordle.words) - words = WordStats.order_by_scores(wordle.words, scores) + def solve(wordlist, right_word, guesses, complement) do + guess = best_guess(wordlist, complement) + {guesses, feedback} = Game.guess(right_word, guess, guesses) + complement = Solver.complement(complement, guess) + wordlist = Solver.feedback(wordlist, guess, feedback) - %Wordle{wordle | scores: scores, words: words} + solve(wordlist, right_word, guesses, complement) end - @spec suggest(Wordle.t()) :: Wordle.t() - def suggest(wordle = %Wordle{words: []}), do: wordle - - def suggest(wordle) do - [best_guess | _tl] = wordle.words + @spec feedback([binary], binary, binary) :: [binary] + defdelegate feedback(wordlist, guess, feedback), to: Solver - %Wordle{wordle | suggestions: [best_guess | wordle.suggestions]} - end + @spec feedback([binary], binary) :: [binary] + defdelegate feedback(wordlist, feedback), to: Solver - @spec build_feedback(Wordle.t(), binary) :: binary - defp build_feedback(wordle, right_word) do - [suggestion | _] = wordle.words - - suggestion - |> String.codepoints() - |> Enum.with_index() - |> Enum.map_join(fn {letter, pos} -> - cond do - letter == String.at(right_word, pos) -> "2" - String.contains?(right_word, letter) -> "1" - true -> "0" - end - end) - end + @spec best_guess([binary], [binary]) :: binary + def best_guess(_wordlist, [top_score_complement | _]), do: top_score_complement + def best_guess([top_score_word | _], []), do: top_score_word end diff --git a/lib/wordle/game.ex b/lib/wordle/game.ex new file mode 100644 index 0000000..863866d --- /dev/null +++ b/lib/wordle/game.ex @@ -0,0 +1,40 @@ +defmodule Wordle.Game do + @moduledoc ~S""" + Basic state of an wordle game. The struct in this file retains information about the game, + including the expected word and which guesses have already been taken. + + Usage: + iex> {guesses, _feedback} = Game.guess("word", "test") + {["test"], "0000"} + iex> {_guesses, _feedback} = Game.guess("word", "dont", guesses) + {["dont", "test"], "1200"} + """ + + defmodule UnsolvableError do + defexception [:message] + end + + @spec guess(binary, binary, [binary]) :: {[binary], binary} + def guess(right_word, guess, guesses \\ []) do + if String.length(right_word) != String.length(guess) do + raise ArgumentError, + "guessed word #{guess}, but it should have been #{String.length(right_word)} characters long." + end + + guesses = [guess | guesses] + + feedback = + guess + |> String.codepoints() + |> Enum.with_index() + |> Enum.map_join(fn {letter, pos} -> + cond do + letter == String.at(right_word, pos) -> "2" + String.contains?(right_word, letter) -> "1" + true -> "0" + end + end) + + {guesses, feedback} + end +end diff --git a/lib/wordle/solver.ex b/lib/wordle/solver.ex new file mode 100644 index 0000000..4d3f82d --- /dev/null +++ b/lib/wordle/solver.ex @@ -0,0 +1,65 @@ +defmodule Wordle.Solver do + @moduledoc ~S""" + A basic module used to filter (previously sorted) words until it finds the correct one. + + Usage: + + iex> words = ~w(done gone stay play) + iex> Solver.feedback(words, "stay", "0022") + ["play"] + iex> Solver.feedback(words, "0222") # assumes the first word, which is "done" + ["gone"] + """ + + @spec feedback([binary], binary) :: [binary] + def feedback([best_guess | _] = wordlist, feedback) do + feedback(wordlist, best_guess, feedback) + end + + @spec feedback([binary], binary, binary) :: [binary] + def feedback(wordlist, guess, feedback) do + feedback + |> String.codepoints() + |> Enum.with_index() + |> Enum.reduce(wordlist, fn {letter_feedback, pos}, w -> + letter = String.at(guess, pos) + update_with_letter_feedback(w, letter, pos, letter_feedback) + end) + end + + @spec complement([binary], binary) :: [binary] + def complement(wordlist, guess) do + guess + |> String.codepoints() + |> Enum.uniq() + |> Enum.reduce(wordlist, fn letter, wordlist -> + Enum.reject(wordlist, &String.contains?(&1, letter)) + end) + end + + @spec update_with_letter_feedback([binary], binary, integer, binary) :: [binary] + defp update_with_letter_feedback(wordlist, letter, position, feedback) do + case feedback do + "0" -> wrong_letter(wordlist, letter) + "1" -> wrong_position(wordlist, letter, position) + "2" -> right_position(wordlist, letter, position) + end + end + + @spec wrong_letter([binary], binary) :: [binary] + defp wrong_letter(wordlist, letter) do + Enum.reject(wordlist, &String.contains?(&1, letter)) + end + + @spec wrong_position([binary], binary, integer) :: [binary] + defp wrong_position(wordlist, letter, position) do + wordlist + |> Enum.filter(&String.contains?(&1, letter)) + |> Enum.reject(&(String.at(&1, position) == letter)) + end + + @spec right_position([binary], binary, integer) :: [binary] + defp right_position(wordlist, letter, position) do + Enum.filter(wordlist, &(String.at(&1, position) == letter)) + end +end diff --git a/test/integration_test.exs b/test/integration_test.exs index 8fd321a..272e5f5 100644 --- a/test/integration_test.exs +++ b/test/integration_test.exs @@ -1,37 +1,37 @@ defmodule IntegrationTest do use ExUnit.Case, async: true - @max_attempts 12 - @letter_count 4 + @max_attempts 11 + @letter_count 5 @moduletag :integration - setup :wordle + setup :wordlist describe "Wordle.solve/2" do - test "solves every case in less than #{@max_attempts} attempts", %{wordle: wordle} do - wordle.words + test "solves every case in less than #{@max_attempts} attempts", %{words: words} do + words |> Enum.with_index() |> Enum.each(fn {right_word, index} -> - assert {:ok, %Wordle{suggestions: attempts}} = Wordle.solve(wordle, right_word), + assert {:ok, guesses} = Wordle.solve(words, right_word), "Could not solve for #{right_word} at position #{index}." - assert length(attempts) <= @max_attempts, - "Expected to succeed with less than #{@max_attempts} attempts, got [#{attempts |> Enum.join(", ")}]. Failed with word #{right_word} at position #{index}." + assert length(guesses) <= @max_attempts, + "Expected to succeed with less than #{@max_attempts} attempts, got [#{guesses |> Enum.join(", ")}]. Failed with word #{right_word} at position #{index}." end) end end - defp wordle(context) do - wordle = - "dicts/test.txt" + defp wordlist(context) do + words = + "dicts/pt_br.txt" |> Parser.import_dictionary() |> Parser.trim() + |> Language.normalize(:pt_br) |> Parser.filter_number_of_letters(@letter_count) |> Parser.filter_valid() - |> Language.normalize(:en) - |> Wordle.new() + |> WordStats.order_by_scores() context - |> Map.put(:wordle, wordle) + |> Map.put(:words, words) end end diff --git a/test/word_stats_test.exs b/test/word_stats_test.exs index 070cdca..8c78964 100644 --- a/test/word_stats_test.exs +++ b/test/word_stats_test.exs @@ -7,10 +7,10 @@ defmodule WordStatsTest do words = ~w(abcd ab ab) assert WordStats.letter_frequencies(words) == %{ - "a" => 3 / 8, - "b" => 3 / 8, - "c" => 1 / 8, - "d" => 1 / 8 + "a" => 3, + "b" => 3, + "c" => 1, + "d" => 1 } end end diff --git a/test/wordle/game_test.exs b/test/wordle/game_test.exs new file mode 100644 index 0000000..edf4ff2 --- /dev/null +++ b/test/wordle/game_test.exs @@ -0,0 +1,21 @@ +defmodule Wordle.GameTest do + use ExUnit.Case, async: true + alias Wordle.Game + doctest Game + + describe "guess/2" do + test "returns a string of 0, 1 and 2 as feedback for a guessed word" do + word = "word" + assert {guesses, "0200"} = Game.guess(word, "some") + assert ["some"] = guesses + end + + test "raises in case of length mismatch" do + word = "word" + + assert_raise(ArgumentError, fn -> + {_game, _feedback} = Game.guess(word, "invalid_word") + end) + end + end +end diff --git a/test/wordle/solver_test.exs b/test/wordle/solver_test.exs new file mode 100644 index 0000000..e1fa61a --- /dev/null +++ b/test/wordle/solver_test.exs @@ -0,0 +1,26 @@ +defmodule Wordle.SolverTest do + use ExUnit.Case, async: true + + alias Wordle.Solver + + doctest Solver + + describe "feedback/2" do + test "filters words that do not comply with given feedback" do + words = ~w(small ghost doing great scare) + assert ["scare"] = Solver.feedback(words, "great", "01110") + end + + test "uses first word on the list when a word isn't given" do + words = ~w(small ghost doing great scare) + assert ["scare"] = Solver.feedback(words, "20200") + end + end + + describe "complement/2" do + test "returns a list of words which does not contain letters from last guess" do + words = ~w(small ghost doing great scare) + assert ["small", "scare"] = Solver.complement(words, "doing") + end + end +end diff --git a/test/wordle_test.exs b/test/wordle_test.exs index 80cb255..0d39c2c 100644 --- a/test/wordle_test.exs +++ b/test/wordle_test.exs @@ -2,45 +2,32 @@ defmodule WordleTest do use ExUnit.Case, async: true doctest Wordle - setup :wordle + setup :words - describe "wrong_letter/2" do - test "removes words that contain a given letter", %{wordle: wordle} do - assert %Wordle{words: words} = Wordle.wrong_letter(wordle, "e") - assert words == ~w(stay yoga dont) - end - end - - describe "wrong_position/3" do - test "keeps only words that contain a given letter", %{wordle: wordle} do - assert %Wordle{words: words} = Wordle.wrong_position(wordle, "e", 3) - assert words == ~w(easy deal heal fret test feel) + describe "solve/2" do + test "finds given word and returns ok", %{words: words} do + assert {:ok, _guesses} = Wordle.solve(words, "dont") end - test "removes words that contain given letter in wrong position", %{wordle: wordle} do - assert %Wordle{words: words} = Wordle.wrong_position(wordle, "e", 1) - assert words == ~w(easy fret) + test "keeps track of words tried before coming to the final solution", %{words: words} do + assert {:ok, guesses} = Wordle.solve(words, "dont") + assert ["dont", "easy"] = guesses end end - describe "right_position/3" do - test "keeps only words that contain given letter at exact given position", %{wordle: wordle} do - assert %Wordle{words: words} = Wordle.right_position(wordle, "e", 1) - assert words == ~w(deal heal test feel here) + describe "best_guess/2" do + test "suggests first word when there are no guesses", %{words: words} do + assert Wordle.best_guess(words, []) == Enum.at(words, 0) end - end - describe "solve/2" do - test "finds given word and records attempts", %{wordle: wordle} do - assert {:ok, wordle} = Wordle.solve(wordle, "dont") - assert ["dont" | _] = wordle.suggestions + test "suggests first complement word when possible", %{words: words} do + assert Wordle.best_guess(words, ["test"]) == "test" end end - defp wordle(context) do + defp words(context) do words = ~w(easy here fret test heal deal feel yoga stay dont) - wordle = Wordle.new(words) - Map.put(context, :wordle, wordle) + Map.put(context, :words, words) end end