initial commit
This commit is contained in:
+17
@@ -0,0 +1,17 @@
|
||||
# Virtual environment
|
||||
.venv/
|
||||
|
||||
# Python bytecode
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# Packaging / build artifacts
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Test cache
|
||||
.pytest_cache/
|
||||
|
||||
# Local editor / tool settings (not for sharing)
|
||||
.claude/settings.local.json
|
||||
@@ -0,0 +1,89 @@
|
||||
# Wordle Solver
|
||||
|
||||
A hard-mode Wordle solving assistant. It narrows the candidate word list as you enter each guess and its result, and suggests the best next guesses using a letter-frequency heuristic. Includes a wager tracker for games played with per-letter betting rules.
|
||||
|
||||
## Install
|
||||
|
||||
### With pipx (recommended)
|
||||
|
||||
[pipx](https://pipx.pypa.io) installs the `wordle` command globally in an isolated environment — no virtual environment to manage.
|
||||
|
||||
```bash
|
||||
pipx install .
|
||||
```
|
||||
|
||||
To pick up code changes after editing the source:
|
||||
|
||||
```bash
|
||||
pipx reinstall wordle-solver
|
||||
```
|
||||
|
||||
### With pip (development)
|
||||
|
||||
Requires Python 3.9+.
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
This installs the `wordle` command into the virtual environment's `bin/`.
|
||||
|
||||
### Options
|
||||
|
||||
```
|
||||
wordle [--top N] [--position-weight F] [--presence-weight F]
|
||||
```
|
||||
|
||||
| Flag | Default | Description |
|
||||
|---|---|---|
|
||||
| `--top N` | 10 | Number of suggestions to show after each guess |
|
||||
| `--position-weight F` | 1.0 | Weight on per-position letter frequency in the heuristic |
|
||||
| `--presence-weight F` | 1.0 | Weight on overall letter presence frequency in the heuristic |
|
||||
|
||||
## Usage
|
||||
|
||||
### Assisted mode (playing on an external Wordle board)
|
||||
|
||||
After each guess you make on the Wordle site, enter the word and its color result as a single string of letter–digit pairs:
|
||||
|
||||
```
|
||||
> c2r1a0n0e0
|
||||
```
|
||||
|
||||
Each digit encodes the tile color:
|
||||
|
||||
| Digit | Meaning |
|
||||
|---|---|
|
||||
| `2` | Correct letter, correct spot (green / orange in colorblind mode) |
|
||||
| `1` | Correct letter, wrong spot (yellow / blue in colorblind mode) |
|
||||
| `0` | Letter not in the word (grey / black) |
|
||||
|
||||
The solver prints how many candidates remain, lists them when 20 or fewer are left, and shows the top-ranked hard-mode-valid suggestions.
|
||||
|
||||
### Practice mode
|
||||
|
||||
`/practice` picks a secret word and lets you play through a full game. Type your guess as a plain word — no digits needed, the solver scores it for you. Hard mode is enforced: green letters must stay in their position, and all revealed letters must appear in every subsequent guess.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `help` | Print the help text |
|
||||
| `reset` | Clear the solver state for a new puzzle (keeps wager session total) |
|
||||
| `quit` | Exit |
|
||||
| `/practice` | Start a practice game: solver picks a secret word, scores your guesses |
|
||||
| `/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` |
|
||||
|
||||
## Wager tracking
|
||||
|
||||
The solver tracks a running dollar cost for each guess based on its tile results. Default prices:
|
||||
|
||||
- Correct (green): **$1**
|
||||
- Present (yellow): **$2**
|
||||
- Absent (grey): **$3**
|
||||
|
||||
After each guess the solver prints the cost of that guess, the round total, and the session total. Use `/price` to match whatever betting rules your game uses.
|
||||
@@ -0,0 +1,21 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "wordle-solver"
|
||||
version = "0.1.0"
|
||||
description = "Hard-mode Wordle solving assistant"
|
||||
requires-python = ">=3.9"
|
||||
|
||||
[project.scripts]
|
||||
wordle = "wordle_solver.cli:run"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["wordle_solver*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
wordle_solver = ["data/*.txt"]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest"]
|
||||
@@ -0,0 +1,84 @@
|
||||
import pytest
|
||||
|
||||
from wordle_solver.game import Clue
|
||||
from wordle_solver.cli import parse_line, hard_mode_violation
|
||||
|
||||
G = Clue.GREEN
|
||||
Y = Clue.YELLOW
|
||||
B = Clue.GREY
|
||||
|
||||
|
||||
class TestParseLine:
|
||||
def test_digit_encoding(self):
|
||||
guess, pattern = parse_line("c2r1a0n0e0")
|
||||
assert guess == "crane"
|
||||
assert pattern == (G, Y, B, B, B)
|
||||
|
||||
def test_letter_encoding(self):
|
||||
guess, pattern = parse_line("cGrYaBnBeB")
|
||||
assert guess == "crane"
|
||||
assert pattern == (G, Y, B, B, B)
|
||||
|
||||
def test_spaces_stripped(self):
|
||||
guess, pattern = parse_line("c 2 r 1 a 0 n 0 e 0")
|
||||
assert guess == "crane"
|
||||
assert pattern == (G, Y, B, B, B)
|
||||
|
||||
def test_uppercase_letters_lowercased(self):
|
||||
guess, _ = parse_line("C2R1A0N0E0")
|
||||
assert guess == "crane"
|
||||
|
||||
def test_all_green(self):
|
||||
_, pattern = parse_line("c2r2a2n2e2")
|
||||
assert pattern == (G, G, G, G, G)
|
||||
|
||||
def test_wrong_length_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
parse_line("c2r1a0") # only 3 pairs
|
||||
|
||||
def test_too_long_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
parse_line("c2r1a0n0e0x0") # 6 pairs
|
||||
|
||||
def test_digit_in_letter_position_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
parse_line("12r1a0n0e0") # position 0 is '1', not a letter
|
||||
|
||||
|
||||
class TestHardModeViolation:
|
||||
def test_valid_guess_returns_none(self):
|
||||
green = {0: "c", 1: "r"}
|
||||
from collections import Counter
|
||||
min_count = Counter({"a": 1})
|
||||
assert hard_mode_violation("crane", green, min_count) is None
|
||||
|
||||
def test_wrong_green_position_detected(self):
|
||||
green = {0: "c"}
|
||||
from collections import Counter
|
||||
result = hard_mode_violation("trace", green, Counter())
|
||||
assert result is not None
|
||||
assert "1" in result # position 1 (1-indexed)
|
||||
|
||||
def test_missing_required_letter_detected(self):
|
||||
from collections import Counter
|
||||
min_count = Counter({"r": 1})
|
||||
result = hard_mode_violation("piano", {}, min_count)
|
||||
assert result is not None
|
||||
assert "R" in result
|
||||
|
||||
def test_missing_duplicate_letter_detected(self):
|
||||
# Word needs two 'e's
|
||||
from collections import Counter
|
||||
min_count = Counter({"e": 2})
|
||||
result = hard_mode_violation("crane", {}, min_count)
|
||||
assert result is not None # "crane" has only one 'e'
|
||||
|
||||
def test_satisfies_duplicate_requirement(self):
|
||||
from collections import Counter
|
||||
min_count = Counter({"e": 2})
|
||||
result = hard_mode_violation("geese", {}, min_count)
|
||||
assert result is None # "geese" has three 'e's — satisfies ≥2
|
||||
|
||||
def test_empty_constraints_always_none(self):
|
||||
from collections import Counter
|
||||
assert hard_mode_violation("crane", {}, Counter()) is None
|
||||
@@ -0,0 +1,85 @@
|
||||
import pytest
|
||||
|
||||
from wordle_solver.game import Clue, score_guess, str_to_pattern, pattern_to_str, pattern_to_digits
|
||||
|
||||
G = Clue.GREEN
|
||||
Y = Clue.YELLOW
|
||||
B = Clue.GREY
|
||||
|
||||
|
||||
class TestScoreGuess:
|
||||
def test_all_green(self):
|
||||
assert score_guess("crane", "crane") == (G, G, G, G, G)
|
||||
|
||||
def test_all_grey(self):
|
||||
assert score_guess("brick", "those") == (B, B, B, B, B)
|
||||
|
||||
def test_basic_yellow(self):
|
||||
assert score_guess("trail", "blurt") == (Y, Y, B, B, Y)
|
||||
|
||||
def test_basic_mixed(self):
|
||||
assert score_guess("arise", "spare") == (Y, Y, B, Y, G)
|
||||
|
||||
# Duplicate-letter cases — this is where the two-pass algorithm matters.
|
||||
|
||||
def test_duplicate_in_guess_one_in_answer_first_gets_yellow(self):
|
||||
# 'a' appears twice in "nasal" (pos 1, 3), once in "ultra" (pos 4).
|
||||
# Neither is a green, so pass-2 left-to-right: pos 1 → yellow, pos 3 → grey.
|
||||
assert score_guess("nasal", "ultra") == (B, Y, B, B, Y)
|
||||
|
||||
def test_duplicate_in_guess_one_in_answer_green_takes_priority(self):
|
||||
# "eerie" has 3 e's; "abcee" has 2. The last 'e' in the guess hits green
|
||||
# at position 4; one remaining 'e' in the answer makes the first 'e' yellow;
|
||||
# the middle 'e' has nothing left to match → grey.
|
||||
assert score_guess("eerie", "abcee") == (Y, B, B, B, G)
|
||||
|
||||
def test_duplicate_in_guess_both_in_answer(self):
|
||||
# "sleep" has two 'e's; answer "geese" has three 'e's
|
||||
# both guess 'e's should be coloured (green or yellow)
|
||||
result = score_guess("sleep", "geese")
|
||||
assert result[2] == G # 'e' at pos 2 matches answer pos 2
|
||||
assert result[3] in (G, Y)
|
||||
|
||||
def test_duplicate_in_answer_guess_has_one(self):
|
||||
# "abbey" has two 'b's; "bland" has one 'b' in the wrong position → yellow
|
||||
assert score_guess("bland", "abbey") == (Y, B, Y, B, B)
|
||||
|
||||
def test_guess_equals_answer_all_green(self):
|
||||
for word in ("tales", "quick", "zzzzz"):
|
||||
assert score_guess(word, word) == (G,) * 5
|
||||
|
||||
def test_yellow_not_double_counted(self):
|
||||
# "added" vs "daddy": only two 'd's in answer, so not all three guess-d's get colour
|
||||
result = score_guess("added", "daddy")
|
||||
d_colours = [result[i] for i, c in enumerate("added") if c == "d"]
|
||||
coloured = sum(1 for c in d_colours if c != B)
|
||||
assert coloured <= 3 # answer has three 'd's at most
|
||||
|
||||
|
||||
class TestStrToPattern:
|
||||
def test_digit_encoding(self):
|
||||
assert str_to_pattern("22110") == (G, G, Y, Y, B)
|
||||
|
||||
def test_letter_encoding(self):
|
||||
assert str_to_pattern("GGYYB") == (G, G, Y, Y, B)
|
||||
|
||||
def test_lowercase_letter_encoding(self):
|
||||
assert str_to_pattern("ggyyb") == (G, G, Y, Y, B)
|
||||
|
||||
def test_mixed_encodings(self):
|
||||
assert str_to_pattern("2G1Y0") == (G, G, Y, Y, B)
|
||||
|
||||
def test_invalid_char_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
str_to_pattern("2210X")
|
||||
|
||||
def test_dot_and_dash_are_grey(self):
|
||||
assert str_to_pattern("..---") == (B,) * 5
|
||||
|
||||
|
||||
class TestPatternHelpers:
|
||||
def test_pattern_to_str(self):
|
||||
assert pattern_to_str((G, Y, B)) == "GYB"
|
||||
|
||||
def test_pattern_to_digits(self):
|
||||
assert pattern_to_digits((G, Y, B)) == "210"
|
||||
@@ -0,0 +1,147 @@
|
||||
import pytest
|
||||
|
||||
from wordle_solver.game import Clue, score_guess
|
||||
from wordle_solver.solver import Solver, rank_guesses
|
||||
|
||||
G = Clue.GREEN
|
||||
Y = Clue.YELLOW
|
||||
B = Clue.GREY
|
||||
|
||||
ALL_GREEN = (G, G, G, G, G)
|
||||
|
||||
|
||||
def make_solver(*extra):
|
||||
"""Solver with a small, controlled answer list."""
|
||||
answers = ["crane", "crave", "trace", "grace", "brace"]
|
||||
return Solver(answers, list(extra))
|
||||
|
||||
|
||||
class TestSolverApply:
|
||||
def test_all_candidates_initially(self):
|
||||
s = make_solver()
|
||||
assert set(s.candidates) == {"crane", "crave", "trace", "grace", "brace"}
|
||||
|
||||
def test_apply_narrows_candidates(self):
|
||||
s = make_solver()
|
||||
# scoring "crane" against "crane" → all green → only "crane" remains
|
||||
s.apply("crane", ALL_GREEN)
|
||||
assert s.candidates == ["crane"]
|
||||
|
||||
def test_apply_chain(self):
|
||||
s = make_solver()
|
||||
# first clue: 'c' correct, 'r' present, rest absent/wrong
|
||||
pattern1 = score_guess("crane", "crave")
|
||||
s.apply("crane", pattern1)
|
||||
# 'crave' must still be a candidate; 'grace' and 'brace' may or may not be
|
||||
assert "crave" in s.candidates
|
||||
assert "crane" not in s.candidates
|
||||
|
||||
def test_apply_empty_candidates_on_no_match(self):
|
||||
s = make_solver()
|
||||
# impossible pattern eliminates everything
|
||||
s.apply("zzzzz", ALL_GREEN)
|
||||
assert s.candidates == []
|
||||
|
||||
def test_reset_restores_all_candidates(self):
|
||||
s = make_solver()
|
||||
s.apply("crane", ALL_GREEN)
|
||||
s.reset()
|
||||
assert set(s.candidates) == {"crane", "crave", "trace", "grace", "brace"}
|
||||
|
||||
def test_reset_clears_history(self):
|
||||
s = make_solver()
|
||||
s.apply("crane", ALL_GREEN)
|
||||
s.reset()
|
||||
assert s.history == []
|
||||
|
||||
def test_is_solved_after_all_green(self):
|
||||
s = make_solver()
|
||||
s.apply("crane", ALL_GREEN)
|
||||
assert s.is_solved()
|
||||
|
||||
def test_is_solved_false_initially(self):
|
||||
assert not make_solver().is_solved()
|
||||
|
||||
|
||||
class TestHardModeConstraints:
|
||||
def test_green_positions_extracted(self):
|
||||
s = make_solver()
|
||||
s.apply("crane", (G, B, B, B, B)) # 'c' locked at position 0
|
||||
green, _ = s.hard_mode_constraints()
|
||||
assert green == {0: "c"}
|
||||
|
||||
def test_yellow_minimum_count(self):
|
||||
s = make_solver()
|
||||
s.apply("crane", (B, Y, B, B, B)) # 'r' must appear at least once
|
||||
_, min_count = s.hard_mode_constraints()
|
||||
assert min_count["r"] >= 1
|
||||
|
||||
def test_multiple_greens_accumulated(self):
|
||||
s = make_solver()
|
||||
s.apply("crane", (G, G, B, B, B))
|
||||
s.apply("crave", (G, G, B, G, B))
|
||||
green, _ = s.hard_mode_constraints()
|
||||
assert green[0] == "c"
|
||||
assert green[1] == "r"
|
||||
assert green[3] == "v"
|
||||
|
||||
def test_duplicate_yellow_counts_correctly(self):
|
||||
s = make_solver()
|
||||
# Two 'a's in a guess, both yellow → need at least 2 'a's
|
||||
s.apply("llama", (B, B, Y, B, Y))
|
||||
_, min_count = s.hard_mode_constraints()
|
||||
assert min_count["a"] >= 2
|
||||
|
||||
|
||||
class TestHardModePool:
|
||||
def test_no_history_returns_all_words(self):
|
||||
s = make_solver("extra1", "extra2")
|
||||
pool = s.hard_mode_pool()
|
||||
assert set(pool) == set(s.all_words)
|
||||
|
||||
def test_green_constraint_filters_pool(self):
|
||||
s = make_solver()
|
||||
s.apply("crane", (G, B, B, B, B)) # first letter must be 'c'
|
||||
pool = s.hard_mode_pool()
|
||||
assert all(w[0] == "c" for w in pool)
|
||||
|
||||
def test_yellow_constraint_filters_pool(self):
|
||||
s = make_solver()
|
||||
s.apply("crane", (B, Y, B, B, B)) # 'r' must be present
|
||||
pool = s.hard_mode_pool()
|
||||
assert all("r" in w for w in pool)
|
||||
|
||||
def test_word_violating_green_excluded(self):
|
||||
s = make_solver()
|
||||
s.apply("crane", (G, B, B, B, B)) # 'c' at position 0
|
||||
pool = s.hard_mode_pool()
|
||||
assert "trace" not in pool # starts with 't'
|
||||
|
||||
|
||||
class TestRankGuesses:
|
||||
def test_returns_at_most_top_n(self):
|
||||
answers = ["crane", "crave", "trace", "grace", "brace"]
|
||||
pool = answers[:]
|
||||
results = rank_guesses(pool, answers, top_n=3)
|
||||
assert len(results) <= 3
|
||||
|
||||
def test_possible_answers_tagged(self):
|
||||
answers = ["crane", "crave"]
|
||||
pool = answers + ["zzzzz"]
|
||||
results = rank_guesses(pool, answers, top_n=10)
|
||||
by_word = {w: is_ans for w, _, is_ans in results}
|
||||
assert by_word["crane"] is True
|
||||
assert by_word["crave"] is True
|
||||
assert by_word["zzzzz"] is False
|
||||
|
||||
def test_scores_descending(self):
|
||||
answers = ["crane", "crave", "trace", "grace", "brace"]
|
||||
results = rank_guesses(answers, answers, top_n=5)
|
||||
scores = [s for _, s, _ in results]
|
||||
assert scores == sorted(scores, reverse=True)
|
||||
|
||||
def test_single_candidate_returned(self):
|
||||
answers = ["crane"]
|
||||
results = rank_guesses(answers, answers, top_n=5)
|
||||
assert len(results) == 1
|
||||
assert results[0][0] == "crane"
|
||||
@@ -0,0 +1,79 @@
|
||||
from wordle_solver.game import Clue
|
||||
from wordle_solver.wager import Wager
|
||||
|
||||
G = Clue.GREEN
|
||||
Y = Clue.YELLOW
|
||||
B = Clue.GREY
|
||||
|
||||
|
||||
class TestWagerCost:
|
||||
def test_all_correct_costs_one_each(self):
|
||||
w = Wager()
|
||||
assert w.cost_of((G, G, G, G, G)) == 5
|
||||
|
||||
def test_all_present_costs_two_each(self):
|
||||
w = Wager()
|
||||
assert w.cost_of((Y, Y, Y, Y, Y)) == 10
|
||||
|
||||
def test_all_absent_costs_three_each(self):
|
||||
w = Wager()
|
||||
assert w.cost_of((B, B, B, B, B)) == 15
|
||||
|
||||
def test_mixed_cost(self):
|
||||
w = Wager()
|
||||
assert w.cost_of((G, Y, B, G, B)) == 1 + 2 + 3 + 1 + 3
|
||||
|
||||
|
||||
class TestWagerAccumulation:
|
||||
def test_add_accumulates_round_and_session(self):
|
||||
w = Wager()
|
||||
w.add((G, G, G, G, G)) # cost 5
|
||||
w.add((B, B, B, B, B)) # cost 15
|
||||
assert w.round_total == 20
|
||||
assert w.session_total == 20
|
||||
|
||||
def test_new_round_resets_round_total_only(self):
|
||||
w = Wager()
|
||||
w.add((G, G, G, G, G)) # cost 5
|
||||
w.new_round()
|
||||
w.add((B, B, B, B, B)) # cost 15 in new round
|
||||
assert w.round_total == 15
|
||||
assert w.session_total == 20 # unchanged across rounds
|
||||
|
||||
def test_add_returns_cost_of_that_guess(self):
|
||||
w = Wager()
|
||||
cost = w.add((G, Y, B, G, B))
|
||||
assert cost == 1 + 2 + 3 + 1 + 3
|
||||
|
||||
def test_fresh_wager_starts_at_zero(self):
|
||||
w = Wager()
|
||||
assert w.round_total == 0
|
||||
assert w.session_total == 0
|
||||
|
||||
|
||||
class TestWagerPrices:
|
||||
def test_set_prices_changes_cost(self):
|
||||
w = Wager()
|
||||
w.set_prices(10, 20, 30)
|
||||
assert w.cost_of((G, Y, B)) == 60
|
||||
|
||||
def test_reset_via_set_prices_default(self):
|
||||
w = Wager()
|
||||
w.set_prices(10, 20, 30)
|
||||
w.set_prices(1, 2, 3)
|
||||
assert w.cost_of((G, Y, B)) == 6
|
||||
|
||||
def test_prices_str_contains_values(self):
|
||||
w = Wager()
|
||||
s = w.prices_str()
|
||||
assert "$1" in s
|
||||
assert "$2" in s
|
||||
assert "$3" in s
|
||||
|
||||
def test_prices_str_after_custom_prices(self):
|
||||
w = Wager()
|
||||
w.set_prices(5, 10, 15)
|
||||
s = w.prices_str()
|
||||
assert "$5" in s
|
||||
assert "$10" in s
|
||||
assert "$15" in s
|
||||
@@ -0,0 +1 @@
|
||||
"""Hard-mode Wordle solving assistant."""
|
||||
@@ -0,0 +1,4 @@
|
||||
from .cli import run
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Interactive REPL: enter each guess and its result, get ranked suggestions back."""
|
||||
|
||||
import argparse
|
||||
import random
|
||||
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
|
||||
|
||||
HELP_TEXT = """\
|
||||
Enter each letter followed by its result digit, e.g.:
|
||||
c2r1a0n0e0
|
||||
Digits mean correct/present/absent, not literal color:
|
||||
2 = right letter, right spot (orange in colorblind mode)
|
||||
1 = right letter, wrong spot (blue in colorblind mode)
|
||||
0 = letter not in word (black in colorblind mode)
|
||||
|
||||
In `/practice` mode, just type the guessed word — no digits needed,
|
||||
the tool knows the secret and scores it for you.
|
||||
|
||||
Commands:
|
||||
help show this text
|
||||
reset clear the solver (new puzzle, keeps wager totals)
|
||||
quit exit
|
||||
/practice pick a secret word and let you guess it (hard-mode enforced)
|
||||
/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`
|
||||
"""
|
||||
|
||||
|
||||
def parse_line(line: str):
|
||||
line = line.replace(" ", "")
|
||||
if len(line) != 2 * WORD_LEN:
|
||||
raise ValueError(f"expected {WORD_LEN} letter+digit pairs, e.g. `c2r1a0n0e0`")
|
||||
guess = line[0::2].lower()
|
||||
result = line[1::2]
|
||||
if not guess.isalpha():
|
||||
raise ValueError(f"expected {WORD_LEN} letter+digit pairs, e.g. `c2r1a0n0e0`")
|
||||
return guess, str_to_pattern(result)
|
||||
|
||||
|
||||
def hard_mode_violation(guess: str, green: dict, min_count: Counter):
|
||||
"""Return a human-readable reason `guess` breaks hard mode, or None if it's legal."""
|
||||
for i, ch in green.items():
|
||||
if guess[i] != ch:
|
||||
return f"position {i + 1} must be '{ch.upper()}'"
|
||||
counts = Counter(guess)
|
||||
for ch, need in min_count.items():
|
||||
if counts[ch] < need:
|
||||
return f"must include letter '{ch.upper()}' (need at least {need})"
|
||||
return None
|
||||
|
||||
|
||||
def format_result(guess: str, pattern) -> str:
|
||||
labels = {Clue.GREEN: "correct", Clue.YELLOW: "present", Clue.GREY: "absent"}
|
||||
return " ".join(f"{ch.upper()}:{labels[clue]}" for ch, clue in zip(guess, pattern))
|
||||
|
||||
|
||||
def handle_slash_command(line: str, solver: Solver, wager: Wager, state: dict, answers: list, opts) -> None:
|
||||
parts = line.split()
|
||||
cmd = parts[0].lower()
|
||||
|
||||
if cmd == "/practice":
|
||||
solver.reset()
|
||||
wager.new_round()
|
||||
state["secret"] = random.choice(answers)
|
||||
state["guesses_made"] = 0
|
||||
print(f"Practice round started — I picked a secret word. You have {MAX_GUESSES} guesses.")
|
||||
print(f"Just type the {WORD_LEN}-letter word you're guessing (hard mode enforced).")
|
||||
print_suggestions(solver, opts.top, opts.position_weight, opts.presence_weight)
|
||||
return
|
||||
|
||||
if cmd == "/round":
|
||||
solver.reset()
|
||||
wager.new_round()
|
||||
state["secret"] = None
|
||||
state["guesses_made"] = 0
|
||||
print(f"New round. Prices: {wager.prices_str()} | session total: ${wager.session_total}")
|
||||
print_suggestions(solver, opts.top, opts.position_weight, opts.presence_weight)
|
||||
return
|
||||
|
||||
if cmd == "/price":
|
||||
args = parts[1:]
|
||||
if not args:
|
||||
wager.set_prices(1, 2, 3)
|
||||
print(f"Prices reset to default. {wager.prices_str()}")
|
||||
elif len(args) == 3 and all(a.lstrip("-").isdigit() for a in args):
|
||||
wager.set_prices(*(int(a) for a in args))
|
||||
print(f"Prices updated. {wager.prices_str()}")
|
||||
else:
|
||||
print("Usage: `/price` (reset to $1/$2/$3) or `/price <correct> <present> <absent>`")
|
||||
return
|
||||
print(f"Round total so far: ${wager.round_total} | session total: ${wager.session_total}")
|
||||
return
|
||||
|
||||
print(f"Unknown command: {cmd} (try 'help')")
|
||||
|
||||
|
||||
def print_suggestions(solver: Solver, top_n: int, position_weight: float, presence_weight: float):
|
||||
n_remaining = len(solver.candidates)
|
||||
print(f"\n{n_remaining} possible answer(s) remain.")
|
||||
if n_remaining <= 20:
|
||||
print(" " + ", ".join(sorted(solver.candidates)))
|
||||
|
||||
pool = solver.hard_mode_pool()
|
||||
suggestions = rank_guesses(pool, solver.candidates, top_n=top_n,
|
||||
position_weight=position_weight, presence_weight=presence_weight)
|
||||
if not suggestions:
|
||||
print("No hard-mode-legal words left in the word list (check your entered results).")
|
||||
return
|
||||
|
||||
print(f"\nTop {len(suggestions)} suggestion(s) (hard-mode legal):")
|
||||
for rank, (word, score, is_answer) in enumerate(suggestions, start=1):
|
||||
tag = "possible answer" if is_answer else "scout"
|
||||
print(f" {rank:>2}. {word:<6} (heuristic {score:.3f}, {tag})")
|
||||
|
||||
|
||||
def run(args=None):
|
||||
parser = argparse.ArgumentParser(description="Hard-mode Wordle solving assistant.")
|
||||
parser.add_argument("--top", type=int, default=10, help="number of suggestions to show (default: 10)")
|
||||
parser.add_argument("--position-weight", type=float, default=1.0,
|
||||
help="weight on per-position letter frequency (default: 1.0)")
|
||||
parser.add_argument("--presence-weight", type=float, default=1.0,
|
||||
help="weight on overall letter presence frequency (default: 1.0)")
|
||||
opts = parser.parse_args(args)
|
||||
|
||||
answers = load_answers()
|
||||
extra_guesses = load_extra_guesses()
|
||||
solver = Solver(answers, extra_guesses)
|
||||
wager = Wager()
|
||||
state = {"secret": None, "guesses_made": 0}
|
||||
|
||||
print("Wordle hard-mode assistant. Type 'help' for instructions.")
|
||||
print(f"Wager prices: {wager.prices_str()}")
|
||||
print_suggestions(solver, opts.top, opts.position_weight, opts.presence_weight)
|
||||
|
||||
while True:
|
||||
try:
|
||||
line = input("\n> ").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print()
|
||||
break
|
||||
|
||||
if not line:
|
||||
continue
|
||||
if line.lower() in ("quit", "exit", "q"):
|
||||
break
|
||||
if line.lower() == "help":
|
||||
print(HELP_TEXT)
|
||||
continue
|
||||
if line.lower() == "reset":
|
||||
solver.reset()
|
||||
state["secret"] = None
|
||||
state["guesses_made"] = 0
|
||||
print("Reset.")
|
||||
print_suggestions(solver, opts.top, opts.position_weight, opts.presence_weight)
|
||||
continue
|
||||
if line.startswith("/"):
|
||||
handle_slash_command(line, solver, wager, state, answers, opts)
|
||||
continue
|
||||
|
||||
if state["secret"] is not None:
|
||||
guess = line.lower()
|
||||
if len(guess) != WORD_LEN or not guess.isalpha():
|
||||
print(f"Guess must be a {WORD_LEN}-letter word.")
|
||||
continue
|
||||
if guess not in solver.all_words:
|
||||
print("Not in word list.")
|
||||
continue
|
||||
green, min_count = solver.hard_mode_constraints()
|
||||
violation = hard_mode_violation(guess, green, min_count)
|
||||
if violation:
|
||||
print(f"Hard mode violation: {violation}")
|
||||
continue
|
||||
|
||||
pattern = score_guess(guess, state["secret"])
|
||||
solver.apply(guess, pattern)
|
||||
state["guesses_made"] += 1
|
||||
cost = wager.add(pattern)
|
||||
print(format_result(guess, pattern))
|
||||
print(f"Cost: ${cost} | round total: ${wager.round_total} | session total: ${wager.session_total}")
|
||||
|
||||
if pattern == (Clue.GREEN,) * WORD_LEN:
|
||||
print(f"Solved in {state['guesses_made']} guess(es)! The word was {guess.upper()} \U0001F389")
|
||||
print("'/practice' for another word, '/round' to switch back to manual mode.")
|
||||
state["secret"] = None
|
||||
continue
|
||||
if state["guesses_made"] >= MAX_GUESSES:
|
||||
print(f"Out of guesses. The word was {state['secret'].upper()}.")
|
||||
state["secret"] = None
|
||||
continue
|
||||
|
||||
print_suggestions(solver, opts.top, opts.position_weight, opts.presence_weight)
|
||||
continue
|
||||
|
||||
try:
|
||||
guess, pattern = parse_line(line)
|
||||
except ValueError as e:
|
||||
print(f"Couldn't parse that: {e}")
|
||||
continue
|
||||
|
||||
solver.apply(guess, pattern)
|
||||
cost = wager.add(pattern)
|
||||
print(f"Cost: ${cost} | round total: ${wager.round_total} | session total: ${wager.session_total}")
|
||||
|
||||
if pattern == (Clue.GREEN,) * WORD_LEN:
|
||||
print(f"Solved: {guess.upper()} \U0001F389 ('/round' to start a new round)")
|
||||
continue
|
||||
|
||||
if not solver.candidates:
|
||||
print("No remaining candidates match all the results entered so far — "
|
||||
"double check the guess/result pairs, or 'reset' to start over.")
|
||||
continue
|
||||
|
||||
print_suggestions(solver, opts.top, opts.position_weight, opts.presence_weight)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
||||
"""Feedback simulation matching Wordle's own coloring algorithm."""
|
||||
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class Clue(IntEnum):
|
||||
GREY = 0
|
||||
YELLOW = 1
|
||||
GREEN = 2
|
||||
|
||||
|
||||
CLUE_CHARS = {Clue.GREEN: "G", Clue.YELLOW: "Y", Clue.GREY: "B"}
|
||||
|
||||
# Accept a few common shorthand alphabets when parsing user input.
|
||||
_CHAR_TO_CLUE = {
|
||||
"G": Clue.GREEN, "2": Clue.GREEN,
|
||||
"Y": Clue.YELLOW, "1": Clue.YELLOW,
|
||||
"B": Clue.GREY, "0": Clue.GREY, ".": Clue.GREY, "-": Clue.GREY,
|
||||
}
|
||||
|
||||
|
||||
def score_guess(guess: str, answer: str) -> tuple:
|
||||
"""Return the clue Wordle would show for `guess` against the true `answer`.
|
||||
|
||||
Two-pass algorithm: greens first, then yellows against leftover letters,
|
||||
so duplicate letters are handled the same way the real game does.
|
||||
"""
|
||||
n = len(guess)
|
||||
result = [Clue.GREY] * n
|
||||
remaining = list(answer)
|
||||
|
||||
for i in range(n):
|
||||
if guess[i] == remaining[i]:
|
||||
result[i] = Clue.GREEN
|
||||
remaining[i] = None
|
||||
|
||||
for i in range(n):
|
||||
if result[i] == Clue.GREEN:
|
||||
continue
|
||||
letter = guess[i]
|
||||
if letter in remaining:
|
||||
result[i] = Clue.YELLOW
|
||||
remaining[remaining.index(letter)] = None
|
||||
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def pattern_to_str(pattern) -> str:
|
||||
return "".join(CLUE_CHARS[c] for c in pattern)
|
||||
|
||||
|
||||
def pattern_to_digits(pattern) -> str:
|
||||
return "".join(str(int(c)) for c in pattern)
|
||||
|
||||
|
||||
def str_to_pattern(s: str) -> tuple:
|
||||
s = s.strip()
|
||||
try:
|
||||
return tuple(_CHAR_TO_CLUE[ch.upper()] for ch in s)
|
||||
except KeyError as e:
|
||||
raise ValueError(f"Unrecognized clue character: {e.args[0]!r}") from None
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Candidate tracking and a frequency/positional heuristic for ranking guesses."""
|
||||
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .game import Clue, score_guess
|
||||
|
||||
WORD_LEN = 5
|
||||
MAX_GUESSES = 6
|
||||
|
||||
|
||||
@dataclass
|
||||
class GuessRecord:
|
||||
guess: str
|
||||
pattern: tuple
|
||||
|
||||
|
||||
@dataclass
|
||||
class Solver:
|
||||
answers: list
|
||||
extra_guesses: list
|
||||
history: list = field(default_factory=list)
|
||||
candidates: list = field(init=False)
|
||||
|
||||
def __post_init__(self):
|
||||
self.candidates = list(self.answers)
|
||||
|
||||
@property
|
||||
def all_words(self) -> list:
|
||||
return self.answers + self.extra_guesses
|
||||
|
||||
def apply(self, guess: str, pattern: tuple) -> None:
|
||||
"""Record a guess/result and narrow the remaining candidates to those
|
||||
consistent with it (by literally re-simulating Wordle's own coloring)."""
|
||||
guess = guess.lower()
|
||||
self.history.append(GuessRecord(guess, pattern))
|
||||
self.candidates = [w for w in self.candidates if score_guess(guess, w) == pattern]
|
||||
|
||||
def reset(self) -> None:
|
||||
self.history.clear()
|
||||
self.candidates = list(self.answers)
|
||||
|
||||
def is_solved(self) -> bool:
|
||||
return bool(self.history) and self.history[-1].pattern == (Clue.GREEN,) * WORD_LEN
|
||||
|
||||
def hard_mode_constraints(self):
|
||||
"""Derive the two hard-mode requirements from history so far:
|
||||
fixed green letters by position, and minimum required count per
|
||||
letter from any green/yellow marks seen."""
|
||||
green = {}
|
||||
min_count = Counter()
|
||||
for rec in self.history:
|
||||
marks_this_guess = Counter()
|
||||
for i, (ch, clue) in enumerate(zip(rec.guess, rec.pattern)):
|
||||
if clue == Clue.GREEN:
|
||||
green[i] = ch
|
||||
marks_this_guess[ch] += 1
|
||||
elif clue == Clue.YELLOW:
|
||||
marks_this_guess[ch] += 1
|
||||
for ch, count in marks_this_guess.items():
|
||||
if count > min_count[ch]:
|
||||
min_count[ch] = count
|
||||
return green, min_count
|
||||
|
||||
def hard_mode_pool(self) -> list:
|
||||
"""Every known word that is a *legal* next guess under hard-mode rules:
|
||||
green letters stay in place, and every yellow/green letter seen so far
|
||||
appears at least as many times as it has been confirmed."""
|
||||
if not self.history:
|
||||
return list(self.all_words)
|
||||
|
||||
green, min_count = self.hard_mode_constraints()
|
||||
pool = []
|
||||
for w in self.all_words:
|
||||
if any(w[i] != ch for i, ch in green.items()):
|
||||
continue
|
||||
counts = Counter(w)
|
||||
if any(counts[ch] < need for ch, need in min_count.items()):
|
||||
continue
|
||||
pool.append(w)
|
||||
return pool
|
||||
|
||||
|
||||
def letter_stats(words: list):
|
||||
"""Per-position and overall-presence letter frequencies across `words`."""
|
||||
n = len(words)
|
||||
position_freq = [Counter() for _ in range(WORD_LEN)]
|
||||
presence_freq = Counter()
|
||||
for w in words:
|
||||
for i, ch in enumerate(w):
|
||||
position_freq[i][ch] += 1
|
||||
for ch in set(w):
|
||||
presence_freq[ch] += 1
|
||||
position_freq = [{ch: c / n for ch, c in pf.items()} for pf in position_freq]
|
||||
presence_freq = {ch: c / n for ch, c in presence_freq.items()}
|
||||
return position_freq, presence_freq
|
||||
|
||||
|
||||
def score_word(word, position_freq, presence_freq, position_weight=1.0, presence_weight=1.0, repeat_penalty=0.85):
|
||||
"""Higher score = letters that are both common overall and likely in this
|
||||
position, favoring new letters over repeats since repeats teach us less."""
|
||||
score = 0.0
|
||||
seen = set()
|
||||
for i, ch in enumerate(word):
|
||||
score += position_weight * position_freq[i].get(ch, 0.0)
|
||||
gain = presence_weight * presence_freq.get(ch, 0.0)
|
||||
score += gain if ch not in seen else -repeat_penalty * gain
|
||||
seen.add(ch)
|
||||
return score
|
||||
|
||||
|
||||
def rank_guesses(pool, candidates, top_n=10, position_weight=1.0, presence_weight=1.0):
|
||||
"""Rank `pool` words by the heuristic, computed from letter stats over the
|
||||
still-possible `candidates`. Returns (word, score, is_possible_answer)."""
|
||||
position_freq, presence_freq = letter_stats(candidates)
|
||||
candidate_set = set(candidates)
|
||||
scored = [
|
||||
(score_word(w, position_freq, presence_freq, position_weight, presence_weight), w)
|
||||
for w in pool
|
||||
]
|
||||
scored.sort(key=lambda pair: (-pair[0], pair[1]))
|
||||
return [(w, s, w in candidate_set) for s, w in scored[:top_n]]
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Tracks a $-per-letter wager score alongside the solver.
|
||||
|
||||
Default rule: $1 per correct-spot letter, $2 per present-but-wrong-spot
|
||||
letter, $3 per absent letter — tune with the `/price` command if your
|
||||
wager uses different numbers.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .game import Clue
|
||||
|
||||
DEFAULT_PRICES = {Clue.GREEN: 1, Clue.YELLOW: 2, Clue.GREY: 3}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Wager:
|
||||
prices: dict = field(default_factory=lambda: dict(DEFAULT_PRICES))
|
||||
round_total: int = 0
|
||||
session_total: int = 0
|
||||
|
||||
def cost_of(self, pattern) -> int:
|
||||
return sum(self.prices[clue] for clue in pattern)
|
||||
|
||||
def add(self, pattern) -> int:
|
||||
cost = self.cost_of(pattern)
|
||||
self.round_total += cost
|
||||
self.session_total += cost
|
||||
return cost
|
||||
|
||||
def new_round(self) -> None:
|
||||
self.round_total = 0
|
||||
|
||||
def set_prices(self, green: int, yellow: int, grey: int) -> None:
|
||||
self.prices = {Clue.GREEN: green, Clue.YELLOW: yellow, Clue.GREY: grey}
|
||||
|
||||
def prices_str(self) -> str:
|
||||
return (f"correct=${self.prices[Clue.GREEN]} "
|
||||
f"present=${self.prices[Clue.YELLOW]} "
|
||||
f"absent=${self.prices[Clue.GREY]}")
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Loading of the bundled word lists.
|
||||
|
||||
Both files come from the original NYT Wordle source: `answers.txt` is the
|
||||
2,315-word possible-answer list, `guesses.txt` is the additional ~10,657
|
||||
words accepted as valid guesses but never used as answers.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
DATA_DIR = Path(__file__).parent / "data"
|
||||
|
||||
|
||||
def _load(filename: str) -> list:
|
||||
path = DATA_DIR / filename
|
||||
return [w.strip().lower() for w in path.read_text().splitlines() if w.strip()]
|
||||
|
||||
|
||||
def load_answers() -> list:
|
||||
return _load("answers.txt")
|
||||
|
||||
|
||||
def load_extra_guesses() -> list:
|
||||
return _load("guesses.txt")
|
||||
|
||||
|
||||
def load_all_valid_words() -> list:
|
||||
return load_answers() + load_extra_guesses()
|
||||
Reference in New Issue
Block a user