ruby-wordle 0.9.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 911b41c72a106def560cc14de90dce996c354e615054d67ea3d3c150a29dad21
4
- data.tar.gz: 80a2fabaa942be49066e69af631ae473b2f5b774439fb2aa626e7e6748f3498f
3
+ metadata.gz: 51eede7bb7e30665d22be4809b5c09bb3dcfb66e5416c8277bedc9b6bdb1d49a
4
+ data.tar.gz: f405f666093a941be20589aa4c9088f0e656d4fd7bb8d05d2defe1d66133ce10
5
5
  SHA512:
6
- metadata.gz: b363d91c62414d150ae0675ea0345253659d20e7f2202c0c2a2fa5ba516125ace8f3a3023c21878dbf29b0c0d8a990c9ed755d4ac4fc0b2ed3ab47a6998660a3
7
- data.tar.gz: 77816914e691cae8ee15bd5a11d8ff1e2c06b873d88de5bbb5b7c3e2e9676a8cd87d7674c7cbdb0f996546e82c28baea718b904a70a6b6cfb506e4b1d407480e
6
+ metadata.gz: 79c4f52565e7ad89f16e4ac16424e118a229c536d2fa9c9da9f1fb8bc0a04bf9ed98a6eacd82593bad6552fd672e1e3c87a5e391ca38f257c2112852e15207ae
7
+ data.tar.gz: b8942dece7ac63abab3173a7ee381b6b6e0423f5df9c4a90b4a6241e0f1fb33bd42be18d828909437bf1c5fda66d22f43fa003c737b6fe9608adf15eced826f8
data/CHANGELOG.md ADDED
@@ -0,0 +1,19 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0
4
+
5
+ - Changed: The solver takes each guess with its colors on one line (`crane xyxxg`) and supports `undo`, `reset` and `all`.
6
+ - Changed: `ruby-wordle solve GUESS:COLORS ...` prints the candidates without prompting.
7
+ - Changed: The solver shows the turns as tiles and groups the candidates into common and other words, updated in place on a terminal.
8
+ - Changed: The solver ends when a guess gets all green and asks whether to solve another.
9
+ - Changed: The solver ranks the next guess by the expected number of words left instead of listing a random sample.
10
+ - Changed: The game shows the board and a keyboard with the colors of every letter tried, updated in place on a terminal.
11
+ - Changed: After a game, `s` copies the result as emoji squares to the clipboard.
12
+ - Changed: In the game, `?` shows only how many words still fit, and `hint letter` reveals one letter; the answer is never shown during the game, and a game with hints is marked `(assisted)`.
13
+ - Changed: Ruby 3.2 or later is required; input uses Reline.
14
+ - Changed: Tiles use fixed colors that look the same in every terminal theme.
15
+ - Changed: Colors are turned off with `--no-color`, a non-empty `NO_COLOR`, or when the output is not a terminal.
16
+ - Changed: The installed gem includes only the 5-letter word lists. Use the repository for the source lists and `rake wordlists`.
17
+ - Fixed: Repeated letters are colored as in Wordle, and the solver handles them exactly.
18
+ - Fixed: The game no longer picks answers that cannot be typed or are not in the word list.
19
+ - Fixed: End of input and Ctrl-C exit cleanly.
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Ruby-Wordle
2
2
 
3
- A set of ruby scripts to generate word-lists, solve Wordle, and play Wordle on the command line
3
+ Play Wordle and solve Wordle on the command line
4
4
 
5
5
  ⬜️⬜️⬜️⬜️⬜️ \
6
6
  ⬜️⬜️⬜️🟨🟨 \
@@ -12,81 +12,111 @@ A set of ruby scripts to generate word-lists, solve Wordle, and play Wordle on t
12
12
 
13
13
  ### Installation
14
14
 
15
+ Requires Ruby 3.2 or later.
16
+
15
17
  ```
16
- > gem install ruby-wordle
18
+ gem install ruby-wordle
17
19
  ```
18
20
 
19
21
  ----
20
22
 
21
23
  ### Play Wordle
22
24
 
23
- **Usage Example**
24
-
25
- ```plain-text
26
- > ruby-worldle play
27
25
  ```
26
+ ruby-wordle play
27
+ ```
28
+
29
+ <img src="https://github.com/yohasebe/ruby-wordle/raw/main/img/play.png" width="500" alt="A game solved in four guesses" />
30
+
31
+ Guess the 5-letter word in 6 tries. After each guess, the board shows each letter on a green tile (right place), a yellow tile (in the word, elsewhere) or a gray tile (not in the word, or already marked as many times as it occurs), and the keyboard below it shows the best color found for every letter so far. On a terminal, the board is updated in place; when the output below it no longer fits on the screen, the window is resized, or the input has non-ASCII characters, a new board is printed below instead.
32
+
33
+ | Input | Action |
34
+ | --- | --- |
35
+ | a 5-letter word | Make a guess |
36
+ | `?` | Show how many common words still fit your guesses |
37
+ | `hint letter` | Reveal the letter at the first position not yet guessed correctly (once per game, and only while at least two such positions remain) |
38
+ | `q` | Give up and show the answer |
28
39
 
29
- <img src='https://github.com/yohasebe/ruby-wordle/blob/main/img/ruby-wordle-play.png?raw=true' width='500' />
40
+ After a game with at least one guess, type `s` at the `Play again?` prompt to copy the result as emoji squares (🟩🟨⬛) to the clipboard, ready to paste into a post. Hints do not use up a guess, but a game with hints is marked `(assisted)` in the result and the copied text. A letter hint can still be enough to work out the word. Where no clipboard command is available (`pbcopy`, `wl-copy`, `xclip` or `xsel`), the result is printed instead.
30
41
 
31
- You can ask for help by typing "?↵" while playing. In the suggestion list, basic words consisting of 5 different letters are printed in red 🟥 while basic words having the same letters used more than once are printed in blue 🟦
42
+ Answers are picked from a list of about 1,600 common words. Guesses are accepted from a list of about 16,000 words.
32
43
 
33
44
  ----
34
45
 
35
46
  ### Wordle Solver
36
47
 
37
- **Usage Example**
38
-
39
- ```plain-text
40
- > ruby-worldle solver
48
+ ```
49
+ ruby-wordle solve
41
50
  ```
42
51
 
43
- <img src='https://github.com/yohasebe/ruby-wordle/blob/main/img/ruby-wordle-solver.png?raw=true' width='500' />
52
+ Enter each guess you made in a Wordle game together with the colors it got, one character per letter: `g` green, `y` yellow, `x` gray (`2`, `1`, `0` also work).
44
53
 
45
- In the result list, basic words consisting of 5 different letters are printed in red 🟥 while basic words having the same letters used more than once are printed in blue 🟦
54
+ ```
55
+ 1 > crane yxxyg
56
+ 2 > niche yxyxg
57
+ ```
46
58
 
47
- ----
59
+ <img src="https://github.com/yohasebe/ruby-wordle/raw/main/img/solve.png" width="700" alt="The solver after two guesses" />
48
60
 
49
- ### Word Lists
61
+ After each line, the solver shows how many words are still possible, the best next guesses, and the possible words, grouped into common words from the basic word list and other words. Every listed word fits the colors. On a terminal, the turns and the possible words are updated in place, as in the game. When a guess gets all green, the solver asks whether to solve another.
50
62
 
51
- #### Word List Generator Script
63
+ The next guesses are ranked by the expected number of common words left after playing them (shown as `~3.8`). Words that can still be the answer are in bold; the others are in gray (marked `(probe)` without colors) and are good for narrowing down the answer but cannot be it.
52
64
 
53
- 🟢 `lib/ruby-wordle/script-filter.rb`
65
+ | Input | Action |
66
+ | --- | --- |
67
+ | `GUESS COLORS` | Add a turn, e.g. `crane xyxxg` |
68
+ | `undo` | Remove the last turn |
69
+ | `reset` | Start over |
70
+ | `all` | List every possible word |
71
+ | `q` | Quit |
54
72
 
55
- A ruby script to generate *n*-letter word lists (`word-list.txt` and `word-list-uniq.txt`)
73
+ To get the result without prompts, pass the turns as arguments:
56
74
 
57
- The default *n* value is 5. Change `NUM_LETTERS` if necessary, and run the script as follows. A directory will be created automatically with the title having the number specified in the script (e.g. `word-lists/5-letters`, `word-lists/6-letters`) and resulting word lists will be stored in that directory.
75
+ ```
76
+ ruby-wordle solve crane:xxxxx pilot:yygxy
77
+ ```
58
78
 
59
79
  ----
60
80
 
61
- #### Lists of English Words of 5 Letters (useful to solve Wordle)
62
-
63
- 🟡 `word-lists/5-letters/word-list.txt`
81
+ ### Colors
64
82
 
65
- A list of five-letter words, generated from the original word list.
83
+ Colors are off when the output is not a terminal, when `NO_COLOR` is set to a non-empty value, or with `--no-color`. Without colors, `[A]` is green, `(A)` is yellow and a lowercase letter is gray.
66
84
 
67
- 🟡 `word-lists/5-letters/word-list-uniq-letters.txt`
85
+ ----
68
86
 
69
- A list of five-letter words consisting of five different letters, generated from the original word list.
87
+ ### Word Lists
70
88
 
71
- 🟡 `word-lists/5-letters/word-list-basic.txt`
89
+ The 5-letter lists used by the game and the solver are in `word-lists/5-letters`:
72
90
 
73
- A list of five-letter words, generated from the basic word list.
91
+ | File | Contents |
92
+ | --- | --- |
93
+ | `word-list.txt` | All 5-letter words (accepted guesses) |
94
+ | `word-list-uniq-letters.txt` | All 5-letter words with five different letters |
95
+ | `word-list-basic.txt` | Common 5-letter words (answers) |
96
+ | `word-list-basic-uniq-letters.txt` | Common 5-letter words with five different letters |
74
97
 
75
- 🟡 `word-lists/5-letters/word-list-basic-uniq-letters.txt`
98
+ They are generated from two source lists in the repository:
76
99
 
77
- A list of five-letter words consisting of five different letters, generated from the basic word list.
100
+ - `word-lists/word-list-original.txt`: about 370,000 English words, based on `words_alpha.txt` in [dwyl/english-words](https://github.com/dwyl/english-words)
101
+ - `word-lists/word-list-basic.txt`: about 13,000 basic English words
78
102
 
79
- ----
103
+ Only alphabetic words are kept, and capitalized entries in the basic list (proper nouns) are left out of the common words. To generate lists of another length into `word-lists/N-letters`, run in a clone of the repository:
80
104
 
81
- #### Original/Basic Lists of English Words
105
+ ```
106
+ bundle install
107
+ bundle exec rake wordlists LENGTH=6
108
+ ```
82
109
 
83
- ⚪️ `word-lists/word-list-original.txt`
110
+ ----
84
111
 
85
- A large word list based on `words_alpha.txt` in [dwyl/english-words](https://github.com/dwyl/english-words), containing more than 370,000 items.
112
+ ### Development
86
113
 
87
- ⚪️ `word-lists/word-list-basic.txt`
114
+ ```
115
+ bundle install
116
+ bundle exec rake test
117
+ ```
88
118
 
89
- A smaller word list containing about 13,000 basic English words.
119
+ The screenshots in `img/` are recorded from `img/*.tape` with `bundle exec rake screenshots`, which requires [VHS](https://github.com/charmbracelet/vhs) and ttyd.
90
120
 
91
121
  ----
92
122
 
data/bin/ruby-wordle CHANGED
@@ -1,24 +1,6 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
- $LOAD_PATH << File.join( File.dirname(__FILE__), '..', 'lib')
4
- $LOAD_PATH << File.join( File.dirname(__FILE__), '..', 'lib', 'ruby-wordle')
5
-
6
- require 'version'
7
- require 'script-play'
8
- require 'script-solver'
9
-
10
- if ARGV[0] == "-v" || ARGV[0] == "--version"
11
- puts "Ruby Wordle #{RubyWordle::VERSION}"
12
- exit
13
- elsif ARGV[0] == "play" || ARGV[0] == "play" || ARGV[0] == "p"
14
- RubyWordlePlay::play_wordle
15
- elsif ARGV[0] == "solver" || ARGV[0] == "solve" || ARGV[0] == "s"
16
- RubyWordleSolver::run_interactively
17
- else
18
- puts "Ruby Wordle #{RubyWordle::VERSION}"; puts
19
- puts "Usage: ruby-wordle (play|solve)"
20
- puts; puts "See https://github.com/yohasebe/ruby-wordle"
21
- exit
22
- end
23
-
3
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
4
+ require "ruby-wordle"
24
5
 
6
+ exit RubyWordle::CLI.new.run(ARGV)
@@ -0,0 +1,426 @@
1
+ require "ruby-wordle/version"
2
+ require "ruby-wordle/feedback"
3
+ require "ruby-wordle/dictionary"
4
+ require "ruby-wordle/game"
5
+ require "ruby-wordle/solver"
6
+ require "ruby-wordle/style"
7
+
8
+ module RubyWordle
9
+ # Command-line front end for playing and solving.
10
+ class CLI
11
+ KEYBOARD = %w[qwertyuiop asdfghjkl zxcvbnm].freeze
12
+ SUGGESTIONS = 5
13
+ LISTED = 40
14
+ COLUMNS = 8
15
+
16
+ USAGE = <<~TEXT.freeze
17
+ Usage: ruby-wordle <command> [options]
18
+
19
+ Commands:
20
+ play Play a game against the computer
21
+ solve Find the answer of a game played elsewhere
22
+ solve GUESS:COLORS ... Print candidates for the given turns and exit
23
+
24
+ COLORS has one character per letter: g = green, y = yellow, x = gray.
25
+ Example: ruby-wordle solve crane:xyxxg
26
+
27
+ Options:
28
+ --no-color Disable colors (also when NO_COLOR is not empty)
29
+ -v, --version Show the version
30
+ -h, --help Show this help
31
+ TEXT
32
+
33
+ CLIPBOARD_COMMANDS = [%w[pbcopy], %w[wl-copy], %w[xclip -selection clipboard], %w[xsel --clipboard --input]].freeze
34
+
35
+ # +clipboard+ takes the text to copy and returns whether it was copied.
36
+ def initialize(input: $stdin, output: $stdout, dictionary: nil, random: Random.new, env: ENV, clipboard: nil)
37
+ @input = input
38
+ @output = output
39
+ @dictionary = dictionary
40
+ @random = random
41
+ @clipboard = clipboard || method(:copy_to_system_clipboard)
42
+ @style = Style.for(output, env)
43
+ end
44
+
45
+ def run(argv)
46
+ args = argv.dup
47
+ @style = Style.new(color: false) if args.delete("--no-color")
48
+ return info("ruby-wordle #{VERSION}") if args.intersect?(%w[-v --version])
49
+ return info(USAGE) if args.empty? || args.intersect?(%w[-h --help])
50
+
51
+ case args.shift
52
+ when "play", "p" then args.empty? ? (play; 0) : usage_error
53
+ when "solve", "solver", "s" then args.empty? ? (solve; 0) : solve_once(args)
54
+ else usage_error
55
+ end
56
+ rescue Interrupt
57
+ say
58
+ 130
59
+ end
60
+
61
+ private
62
+
63
+ def dictionary
64
+ @dictionary ||= Dictionary.default
65
+ end
66
+
67
+ def say(text = "")
68
+ @output.puts text
69
+ count_rows(text)
70
+ end
71
+
72
+ def info(text)
73
+ say text
74
+ 0
75
+ end
76
+
77
+ def usage_error
78
+ say USAGE
79
+ 1
80
+ end
81
+
82
+ # Reline reads and writes the process's terminal, so it is used only
83
+ # when the CLI runs on the standard streams of a terminal.
84
+ def interactive_terminal?
85
+ @input.equal?($stdin) && @output.equal?($stdout) && @input.tty? && @output.tty?
86
+ end
87
+
88
+ # Returns the stripped line, or nil at end of input.
89
+ def ask(prompt)
90
+ line = interactive_terminal? ? read_terminal(prompt) : read_plain(prompt)
91
+ typed = line&.chomp.to_s
92
+ # The width of anything but printable ASCII depends on the terminal.
93
+ @redraw[:reliable] = false if @redraw && !typed.match?(/\A[\x20-\x7e]*\z/)
94
+ count_rows("#{prompt}#{typed}")
95
+ line&.strip&.downcase
96
+ end
97
+
98
+ def read_terminal(prompt)
99
+ require "reline"
100
+ Reline.readline(prompt, true)
101
+ end
102
+
103
+ def read_plain(prompt)
104
+ @output.print prompt
105
+ @input.gets
106
+ end
107
+
108
+ # [height, width] of the terminal, or nil when it is unknown.
109
+ def terminal_size
110
+ require "io/console"
111
+ size = @output.winsize
112
+ size if size.all?(&:positive?)
113
+ rescue StandardError
114
+ nil
115
+ end
116
+
117
+ # --- redraw -------------------------------------------------------------
118
+ #
119
+ # On a terminal, the board (in the solver, the turns and the candidates) is
120
+ # drawn over the previous one together with the prompts, hints and
121
+ # messages printed below it. The rows printed since the board started are
122
+ # counted; when the count may be wrong (the window was resized, or input
123
+ # had characters of unknown width) or the rows no longer fit on the
124
+ # screen, a new board is printed below instead.
125
+
126
+ def start_redraw
127
+ size = interactive_terminal? && terminal_size
128
+ @redraw = size ? { rows: 0, size: size, reliable: true } : nil
129
+ end
130
+
131
+ def stop_redraw
132
+ @redraw = nil
133
+ end
134
+
135
+ def count_rows(text)
136
+ return unless @redraw
137
+
138
+ @redraw[:reliable] = false unless terminal_size == @redraw[:size]
139
+ width = @redraw[:size].last
140
+ text.to_s.chomp.split("\n", -1).then { |lines| lines.empty? ? [""] : lines }.each do |line|
141
+ columns = line.gsub(/\e\[[0-9;]*m/, "").length
142
+ @redraw[:rows] += [(columns - 1) / width + 1, 1].max
143
+ end
144
+ end
145
+
146
+ def rewind
147
+ return unless @redraw
148
+
149
+ size = terminal_size
150
+ rows = @redraw[:rows]
151
+ if @redraw[:reliable] && size == @redraw[:size] && rows.positive? && rows < size.first
152
+ @output.print "\e[#{rows}A\r\e[J"
153
+ end
154
+ @redraw = size && { rows: 0, size: size, reliable: true }
155
+ end
156
+
157
+ def quit?(line)
158
+ line.nil? || %w[q quit exit].include?(line)
159
+ end
160
+
161
+ # --- play ---------------------------------------------------------------
162
+
163
+ def play
164
+ loop do
165
+ game = Game.new(dictionary, random: @random)
166
+ play_round(game)
167
+ options = game.history.empty? ? "[y/N]" : "[y/N/s(hare)]"
168
+ line = ask("Play again? #{options} ")
169
+ if %w[s share].include?(line) && !game.history.empty?
170
+ share(game)
171
+ line = ask("Play again? [y/N] ")
172
+ end
173
+ break unless %w[y yes].include?(line)
174
+ end
175
+ end
176
+
177
+ def play_round(game)
178
+ say @style.paint("Guess the #{game.length}-letter word in #{game.max_attempts} tries.", :bold, :cyan)
179
+ say @style.paint("Type ? for a hint, q to give up.", :gray)
180
+ start_redraw
181
+ draw(game)
182
+
183
+ until game.over?
184
+ line = ask("#{game.history.size + 1}/#{game.max_attempts} > ")
185
+ if quit?(line)
186
+ break
187
+ elsif ["?", "h", "hint", "help"].include?(line)
188
+ hint(game)
189
+ elsif ["hint letter", "letter"].include?(line)
190
+ hint_letter(game)
191
+ elsif !line.empty?
192
+ begin
193
+ game.guess(line)
194
+ draw(game)
195
+ rescue Game::InvalidGuess => e
196
+ say @style.paint(" Sorry, #{e.message}.", :red)
197
+ end
198
+ end
199
+ end
200
+
201
+ stop_redraw
202
+ finish(game)
203
+ end
204
+
205
+ def draw(game)
206
+ rewind
207
+ # Tiles are 3 columns wide with 1-column gaps, so every row has an odd
208
+ # width and the board and keyboard rows share one center column.
209
+ width = ->(tiles) { tiles * 4 - 1 }
210
+ full = width.(KEYBOARD.map(&:size).push(game.length).max)
211
+ indent = ->(tiles) { " " + " " * ((full - width.(tiles)) / 2) }
212
+
213
+ empty = @style.paint(Array.new(game.length, " · ").join(" "), :dim)
214
+ board = game.history.map { |word, pattern| @style.row(word, pattern) } + [empty] * game.attempts_left
215
+ say
216
+ # A blank line between rows keeps tiles of adjacent rows apart.
217
+ say board.map { |row| indent.(game.length) + row }.join("\n\n")
218
+ say
219
+ states = game.letter_states
220
+ KEYBOARD.each do |keys|
221
+ say indent.(keys.size) + keys.chars.map { |c| @style.tile(c, states[c]) }.join(" ")
222
+ end
223
+ say
224
+ return unless game.revealed
225
+
226
+ say " #{letter_clue(game)}"
227
+ say
228
+ end
229
+
230
+ # Hints never name a word: they count the common words that fit the
231
+ # guesses so far, or reveal one letter when asked explicitly.
232
+ def hint(game)
233
+ solver = Solver.new(dictionary, pool: dictionary.answers)
234
+ game.history.each { |word, pattern| solver.add(word, pattern) }
235
+ count = solver.candidates.size
236
+ return say(@style.paint(" No word in the answer list fits; hints are unavailable.", :red)) if count.zero?
237
+
238
+ game.assist!
239
+ say " #{count == 1 ? 'One word fits your clues.' : "#{count} words still fit your clues."}"
240
+ say @style.paint(" For a stronger hint, type: hint letter (reveals one letter, once per game)", :gray) unless game.revealed
241
+ end
242
+
243
+ def hint_letter(game)
244
+ if game.reveal
245
+ say " #{letter_clue(game)}"
246
+ else
247
+ say @style.paint(" A letter hint would give away the word.", :red)
248
+ end
249
+ end
250
+
251
+ def letter_clue(game)
252
+ position, letter = game.revealed
253
+ @style.paint("Hint: letter #{position + 1} is #{letter.upcase}", :yellow)
254
+ end
255
+
256
+ def assisted(game)
257
+ game.assisted? ? " (assisted)" : ""
258
+ end
259
+
260
+ def finish(game)
261
+ say
262
+ if game.won?
263
+ say @style.paint(" Solved in #{game.history.size}/#{game.max_attempts}!#{assisted(game)}", :bold, :green)
264
+ else
265
+ say " The answer was #{@style.paint(game.answer.upcase, :bold)}.#{assisted(game)}"
266
+ end
267
+ say
268
+ end
269
+
270
+ # The result as emoji squares, which keep their colors when pasted.
271
+ def share(game)
272
+ score = game.won? ? game.history.size : "X"
273
+ grid = game.history.map { |_, pattern| @style.emoji(pattern, game.length) }
274
+ text = "ruby-wordle #{score}/#{game.max_attempts}#{assisted(game)}\n\n#{grid.join("\n")}\n"
275
+ say
276
+ if @clipboard.call(text)
277
+ say @style.paint(" Copied the result to the clipboard.", :green)
278
+ else
279
+ text.each_line { |line| say " #{line.chomp}" }
280
+ end
281
+ say
282
+ end
283
+
284
+ def copy_to_system_clipboard(text)
285
+ return false unless interactive_terminal?
286
+
287
+ CLIPBOARD_COMMANDS.any? do |command|
288
+ IO.popen(command, "w", err: File::NULL) { |io| io.write(text) }
289
+ $?.success?
290
+ rescue SystemCallError
291
+ false
292
+ end
293
+ end
294
+
295
+ # --- solve --------------------------------------------------------------
296
+
297
+ def solve
298
+ solver = Solver.new(dictionary)
299
+ say @style.paint("Enter each guess and its colors, e.g. crane xyxxg", :bold, :cyan)
300
+ say @style.paint("g = green, y = yellow, x = gray. Commands: undo, reset, all, q", :gray)
301
+ start_redraw
302
+ report(solver)
303
+
304
+ loop do
305
+ line = ask("#{solver.history.size + 1} > ")
306
+ break if quit?(line)
307
+
308
+ case line
309
+ when "" then next
310
+ when "undo", "u" then solver.undo
311
+ when "reset", "r" then solver.reset
312
+ when "all", "a" then list(solver.candidates, all: true); next
313
+ else
314
+ turn = parse_turn(line)
315
+ next unless turn
316
+
317
+ solver.add(*turn)
318
+ end
319
+ rewind
320
+ show_history(solver)
321
+ report(solver)
322
+ next unless solved?(solver)
323
+ break unless %w[y yes].include?(ask("Solve another? [y/N] "))
324
+
325
+ solver.reset
326
+ rewind
327
+ report(solver)
328
+ end
329
+ stop_redraw
330
+ end
331
+
332
+ # The last turn is all green and every turn fits that word. The word need
333
+ # not be in the word list.
334
+ def solved?(solver)
335
+ answer, pattern = solver.history.last
336
+ return false unless pattern == Feedback.solved(dictionary.length)
337
+
338
+ solver.history.all? { |guess, colors| Feedback.score(guess, answer) == colors }
339
+ end
340
+
341
+ def solve_once(args)
342
+ solver = Solver.new(dictionary)
343
+ args.each do |arg|
344
+ turn = parse_turn(arg)
345
+ return 1 unless turn
346
+
347
+ solver.add(*turn)
348
+ end
349
+ show_history(solver)
350
+ report(solver, all: true)
351
+ solved?(solver) || !solver.candidates.empty? ? 0 : 1
352
+ end
353
+
354
+ # Accepts "crane xyxxg" or "crane:xyxxg".
355
+ def parse_turn(text)
356
+ word, colors = text.downcase.split(/[\s:]+/, 2)
357
+ size = dictionary.length
358
+ pattern = colors && Feedback.parse(colors, size)
359
+ if word.to_s.match?(/\A[a-z]{#{size}}\z/) && pattern
360
+ [word, pattern]
361
+ else
362
+ say @style.paint(" Sorry, write a #{size}-letter word and #{size} colors (g, y, x), e.g. crane xyxxg", :red)
363
+ nil
364
+ end
365
+ end
366
+
367
+ def show_history(solver)
368
+ return if solver.history.empty?
369
+
370
+ say
371
+ say solver.history.map { |word, pattern| " #{@style.row(word, pattern)}" }.join("\n\n")
372
+ end
373
+
374
+ def report(solver, all: false)
375
+ candidates = solver.candidates
376
+ say
377
+ if solved?(solver)
378
+ say @style.paint(" Solved in #{solver.history.size}!", :bold, :green)
379
+ else
380
+ report_candidates(solver, candidates, all)
381
+ end
382
+ say
383
+ end
384
+
385
+ def report_candidates(solver, candidates, all)
386
+ case candidates.size
387
+ when 0
388
+ say @style.paint(" No word matches. Check the colors, or type undo.", :red)
389
+ when 1
390
+ say " The answer is #{@style.paint(candidates.first.upcase, :bold, :green)}."
391
+ else
392
+ common = candidates.count { |w| dictionary.common?(w) }
393
+ say " #{candidates.size} words possible (#{common} common)"
394
+ tries = solver.suggest(SUGGESTIONS).map do |s|
395
+ "#{try_word(s)} #{@style.paint(format('~%.1f', s.expected), :dim)}"
396
+ end
397
+ say " Try: #{tries.join(' ')}"
398
+ list(candidates, all: all)
399
+ end
400
+ end
401
+
402
+ # A suggestion that cannot be the answer is gray, or marked without colors.
403
+ def try_word(suggestion)
404
+ return @style.paint(suggestion.word, :bold) if suggestion.possible
405
+
406
+ @style.color? ? @style.paint(suggestion.word, :gray) : "#{suggestion.word} (probe)"
407
+ end
408
+
409
+ # Common words, then the others, at most LISTED in all unless +all+.
410
+ def list(words, all: false)
411
+ limit = all ? words.size : LISTED
412
+ shown = 0
413
+ common, other = words.partition { |w| dictionary.common?(w) }
414
+ [["Common words", common], ["Other words", other]].each do |label, group|
415
+ group = group.first(limit - shown)
416
+ next if group.empty?
417
+
418
+ say
419
+ say " #{label}:"
420
+ group.each_slice(COLUMNS) { |slice| say " " + slice.join(" ") }
421
+ shown += group.size
422
+ end
423
+ say @style.paint(" ... #{words.size - shown} more (type all)", :gray) if shown < words.size
424
+ end
425
+ end
426
+ end