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 +4 -4
- data/CHANGELOG.md +19 -0
- data/README.md +66 -36
- data/bin/ruby-wordle +3 -21
- data/lib/ruby-wordle/cli.rb +426 -0
- data/lib/ruby-wordle/dictionary.rb +74 -0
- data/lib/ruby-wordle/feedback.rb +73 -0
- data/lib/ruby-wordle/game.rb +84 -0
- data/lib/ruby-wordle/solver.rb +92 -0
- data/lib/ruby-wordle/style.rb +56 -0
- data/lib/ruby-wordle/version.rb +1 -1
- data/lib/ruby-wordle.rb +7 -0
- data/word-lists/5-letters/word-list-basic-uniq-letters.txt +1 -8
- data/word-lists/5-letters/word-list-basic.txt +1 -9
- data/word-lists/5-letters/word-list-uniq-letters.txt +1 -0
- data/word-lists/5-letters/word-list.txt +1 -0
- metadata +25 -29
- data/.gitignore +0 -58
- data/Gemfile +0 -3
- data/Rakefile +0 -1
- data/img/ruby-wordle-play.png +0 -0
- data/img/ruby-wordle-solver.png +0 -0
- data/lib/ruby-wordle/script-filter.rb +0 -48
- data/lib/ruby-wordle/script-play.rb +0 -124
- data/lib/ruby-wordle/script-solver.rb +0 -179
- data/lib/ruby-wordle/script-utils.rb +0 -23
- data/ruby-wordle.gemspec +0 -21
- data/word-lists/word-list-basic.txt +0 -12937
- data/word-lists/word-list-original.txt +0 -370103
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 51eede7bb7e30665d22be4809b5c09bb3dcfb66e5416c8277bedc9b6bdb1d49a
|
|
4
|
+
data.tar.gz: f405f666093a941be20589aa4c9088f0e656d4fd7bb8d05d2defe1d66133ce10
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
```plain-text
|
|
40
|
-
> ruby-worldle solver
|
|
48
|
+
```
|
|
49
|
+
ruby-wordle solve
|
|
41
50
|
```
|
|
42
51
|
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
73
|
+
To get the result without prompts, pass the turns as arguments:
|
|
56
74
|
|
|
57
|
-
|
|
75
|
+
```
|
|
76
|
+
ruby-wordle solve crane:xxxxx pilot:yygxy
|
|
77
|
+
```
|
|
58
78
|
|
|
59
79
|
----
|
|
60
80
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
🟡 `word-lists/5-letters/word-list.txt`
|
|
81
|
+
### Colors
|
|
64
82
|
|
|
65
|
-
|
|
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
|
-
|
|
85
|
+
----
|
|
68
86
|
|
|
69
|
-
|
|
87
|
+
### Word Lists
|
|
70
88
|
|
|
71
|
-
|
|
89
|
+
The 5-letter lists used by the game and the solver are in `word-lists/5-letters`:
|
|
72
90
|
|
|
73
|
-
|
|
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
|
-
|
|
98
|
+
They are generated from two source lists in the repository:
|
|
76
99
|
|
|
77
|
-
|
|
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
|
-
|
|
105
|
+
```
|
|
106
|
+
bundle install
|
|
107
|
+
bundle exec rake wordlists LENGTH=6
|
|
108
|
+
```
|
|
82
109
|
|
|
83
|
-
|
|
110
|
+
----
|
|
84
111
|
|
|
85
|
-
|
|
112
|
+
### Development
|
|
86
113
|
|
|
87
|
-
|
|
114
|
+
```
|
|
115
|
+
bundle install
|
|
116
|
+
bundle exec rake test
|
|
117
|
+
```
|
|
88
118
|
|
|
89
|
-
|
|
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
|
|
4
|
-
|
|
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
|