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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 <g> <y> <b>` | Set custom per-letter prices, e.g. `/price 2 3 5` |
|
||||
| `/addword <word>` | Add a missing word to the answers list, removing it from the guesses-only list if it was there |
|
||||
|
||||
## Wager tracking
|
||||
|
||||
|
||||
+73
-2
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
+29
-1
@@ -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 <g> <y> <b> set per-letter $ values, e.g. `/price 1 2 3`
|
||||
/addword <word> 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 <word>")
|
||||
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')")
|
||||
|
||||
|
||||
|
||||
+25
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user