From e5017efde8c070e08b30a25b508162c671b4d223 Mon Sep 17 00:00:00 2001 From: Will Estes Date: Sat, 25 Jul 2026 05:06:51 -0400 Subject: [PATCH] Add /addword command to add missing Wordle answers Some real Wordle answers aren't in the bundled answers.txt list. This adds a REPL command to append a word to answers.txt and remove it from guesses.txt if present there, since the two lists are meant to be disjoint. Co-Authored-By: Claude Sonnet 5 --- README.md | 1 + tests/test_cli.py | 75 ++++++++++++++++++++++++++++++++++++++++-- tests/test_words.py | 41 +++++++++++++++++++++++ wordle_solver/cli.py | 30 ++++++++++++++++- wordle_solver/words.py | 27 +++++++++++++-- 5 files changed, 169 insertions(+), 5 deletions(-) create mode 100644 tests/test_words.py diff --git a/README.md b/README.md index e5d328d..f07fac1 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ The solver prints how many candidates remain, lists them when 20 or fewer are le | `/round` | Start a new wager round: clears solver state and the round total, exits practice mode | | `/price` | Reset per-letter wager prices to the defaults ($1 correct / $2 present / $3 absent) | | `/price ` | Set custom per-letter prices, e.g. `/price 2 3 5` | +| `/addword ` | Add a missing word to the answers list, removing it from the guesses-only list if it was there | ## Wager tracking diff --git a/tests/test_cli.py b/tests/test_cli.py index ed7295d..411518a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,7 +1,12 @@ +import argparse + import pytest -from wordle_solver.game import Clue -from wordle_solver.cli import parse_line, hard_mode_violation +from wordle_solver import cli +from wordle_solver.game import Clue, score_guess +from wordle_solver.cli import handle_slash_command, parse_line, hard_mode_violation +from wordle_solver.solver import Solver +from wordle_solver.wager import Wager G = Clue.GREEN Y = Clue.YELLOW @@ -82,3 +87,69 @@ class TestHardModeViolation: def test_empty_constraints_always_none(self): from collections import Counter assert hard_mode_violation("crane", {}, Counter()) is None + + +class TestAddWordCommand: + def _solver(self): + return Solver(answers=["crane", "trace"], extra_guesses=["about", "zesty"]) + + def _opts(self): + return argparse.Namespace(top=10, position_weight=1.0, presence_weight=1.0) + + def test_rejects_wrong_arg_count(self, capsys): + solver = self._solver() + handle_slash_command("/addword", solver, Wager(), {}, solver.answers, self._opts()) + assert "Usage" in capsys.readouterr().out + assert solver.answers == ["crane", "trace"] + + def test_rejects_non_alpha_or_wrong_length(self, capsys): + solver = self._solver() + handle_slash_command("/addword abc12", solver, Wager(), {}, solver.answers, self._opts()) + assert "5 letters" in capsys.readouterr().out + assert solver.answers == ["crane", "trace"] + + def test_already_in_answers_short_circuits(self, capsys, monkeypatch): + solver = self._solver() + called = False + + def fake_add(word): + nonlocal called + called = True + return {"added": False, "removed_from_guesses": False} + + monkeypatch.setattr(cli, "add_answer_word", fake_add) + handle_slash_command("/addword crane", solver, Wager(), {}, solver.answers, self._opts()) + + assert "already" in capsys.readouterr().out + assert not called + assert solver.answers == ["crane", "trace"] + + def test_adds_word_and_removes_from_guesses(self, capsys, monkeypatch): + solver = self._solver() + monkeypatch.setattr( + cli, "add_answer_word", + lambda word: {"added": True, "removed_from_guesses": True}, + ) + + handle_slash_command("/addword zesty", solver, Wager(), {}, solver.answers, self._opts()) + + out = capsys.readouterr().out + assert "Added ZESTY" in out + assert "removed it from guesses.txt" in out + assert solver.answers == ["crane", "trace", "zesty"] + assert "zesty" not in solver.extra_guesses + + def test_new_candidate_consistent_with_history_is_added(self, capsys, monkeypatch): + solver = self._solver() + # Derive the pattern "trace" would actually produce against "chase", + # so the new word is consistent with history by construction. + pattern = score_guess("trace", "chase") + solver.apply("trace", pattern) + monkeypatch.setattr( + cli, "add_answer_word", + lambda word: {"added": True, "removed_from_guesses": False}, + ) + + handle_slash_command("/addword chase", solver, Wager(), {}, solver.answers, self._opts()) + + assert "chase" in solver.candidates diff --git a/tests/test_words.py b/tests/test_words.py new file mode 100644 index 0000000..5ad6bbd --- /dev/null +++ b/tests/test_words.py @@ -0,0 +1,41 @@ +from wordle_solver.words import add_answer_word + + +def _write_lists(tmp_path, answers, guesses): + (tmp_path / "answers.txt").write_text("\n".join(answers) + "\n") + (tmp_path / "guesses.txt").write_text("\n".join(guesses) + "\n") + + +class TestAddAnswerWord: + def test_adds_new_word_to_answers(self, tmp_path): + _write_lists(tmp_path, ["crane", "trace"], ["about"]) + + result = add_answer_word("zesty", data_dir=tmp_path) + + assert result == {"added": True, "removed_from_guesses": False} + assert (tmp_path / "answers.txt").read_text().splitlines() == ["crane", "trace", "zesty"] + assert (tmp_path / "guesses.txt").read_text().splitlines() == ["about"] + + def test_word_already_in_answers_is_a_noop(self, tmp_path): + _write_lists(tmp_path, ["crane", "trace"], ["about"]) + + result = add_answer_word("crane", data_dir=tmp_path) + + assert result == {"added": False, "removed_from_guesses": False} + assert (tmp_path / "answers.txt").read_text().splitlines() == ["crane", "trace"] + + def test_word_present_in_guesses_is_removed(self, tmp_path): + _write_lists(tmp_path, ["crane", "trace"], ["about", "zesty"]) + + result = add_answer_word("zesty", data_dir=tmp_path) + + assert result == {"added": True, "removed_from_guesses": True} + assert (tmp_path / "answers.txt").read_text().splitlines() == ["crane", "trace", "zesty"] + assert (tmp_path / "guesses.txt").read_text().splitlines() == ["about"] + + def test_answers_written_sorted(self, tmp_path): + _write_lists(tmp_path, ["trace", "zesty"], []) + + add_answer_word("crane", data_dir=tmp_path) + + assert (tmp_path / "answers.txt").read_text().splitlines() == ["crane", "trace", "zesty"] diff --git a/wordle_solver/cli.py b/wordle_solver/cli.py index a1a9e65..a753ccb 100644 --- a/wordle_solver/cli.py +++ b/wordle_solver/cli.py @@ -7,7 +7,7 @@ from collections import Counter from .game import Clue, score_guess, str_to_pattern from .solver import MAX_GUESSES, WORD_LEN, Solver, rank_guesses from .wager import Wager -from .words import load_answers, load_extra_guesses +from .words import add_answer_word, load_answers, load_extra_guesses HELP_TEXT = """\ Enter each letter followed by its result digit, e.g.: @@ -28,6 +28,8 @@ Commands: /round start a new wager round: clears solver + round total, exits practice /price reset per-letter $ values to the default $1/$2/$3 /price set per-letter $ values, e.g. `/price 1 2 3` + /addword add a missing word to the answers list (and + remove it from the guesses-only list if present) """ @@ -96,6 +98,32 @@ def handle_slash_command(line: str, solver: Solver, wager: Wager, state: dict, a print(f"Round total so far: ${wager.round_total} | session total: ${wager.session_total}") return + if cmd == "/addword": + args = parts[1:] + if len(args) != 1: + print("Usage: /addword ") + return + word = args[0].lower() + if len(word) != WORD_LEN or not word.isalpha(): + print(f"Word must be exactly {WORD_LEN} letters.") + return + if word in solver.answers: + print(f"{word.upper()} is already in the answers list.") + return + + result = add_answer_word(word) + solver.answers.append(word) + solver.answers.sort() + if result["removed_from_guesses"]: + solver.extra_guesses.remove(word) + print(f"Added {word.upper()} to answers.txt and removed it from guesses.txt.") + else: + print(f"Added {word.upper()} to answers.txt.") + + if all(score_guess(rec.guess, word) == rec.pattern for rec in solver.history): + solver.candidates.append(word) + return + print(f"Unknown command: {cmd} (try 'help')") diff --git a/wordle_solver/words.py b/wordle_solver/words.py index 029bd11..d4468d1 100644 --- a/wordle_solver/words.py +++ b/wordle_solver/words.py @@ -10,8 +10,8 @@ from pathlib import Path DATA_DIR = Path(__file__).parent / "data" -def _load(filename: str) -> list: - path = DATA_DIR / filename +def _load(filename: str, data_dir: Path = DATA_DIR) -> list: + path = data_dir / filename return [w.strip().lower() for w in path.read_text().splitlines() if w.strip()] @@ -25,3 +25,26 @@ def load_extra_guesses() -> list: def load_all_valid_words() -> list: return load_answers() + load_extra_guesses() + + +def add_answer_word(word: str, data_dir: Path = DATA_DIR) -> dict: + """Add `word` to the bundled answers list, removing it from the extra + guesses list if it's there (the two lists are meant to be disjoint).""" + word = word.strip().lower() + answers = _load("answers.txt", data_dir) + guesses = _load("guesses.txt", data_dir) + + result = {"added": False, "removed_from_guesses": False} + + if word not in answers: + answers.append(word) + answers.sort() + (data_dir / "answers.txt").write_text("\n".join(answers) + "\n") + result["added"] = True + + if word in guesses: + guesses = [w for w in guesses if w != word] + (data_dir / "guesses.txt").write_text("\n".join(guesses) + "\n") + result["removed_from_guesses"] = True + + return result