wordl-solver 1.4.0 → 2.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 +28 -0
- data/README.md +66 -0
- data/bin/wordl-solver +10 -2
- data/lib/data/allowed_guesses.txt +12972 -0
- data/lib/data/answers.txt +2315 -0
- data/lib/data/opening_scores.json +252 -0
- data/lib/wordl_solver/cli.rb +180 -0
- data/lib/wordl_solver/feedback.rb +58 -0
- data/lib/wordl_solver/filter.rb +66 -0
- data/lib/wordl_solver/game_state.rb +39 -0
- data/lib/wordl_solver/scorer.rb +37 -0
- data/lib/wordl_solver/version.rb +5 -0
- data/lib/wordl_solver/word_lists.rb +13 -0
- data/lib/wordl_solver.rb +28 -54
- metadata +30 -61
- data/.document +0 -5
- data/Gemfile +0 -14
- data/Gemfile.lock +0 -113
- data/README.rdoc +0 -28
- data/Rakefile +0 -49
- data/VERSION +0 -1
- data/lib/5_words.csv +0 -5757
- data/lib/wordl_solver_interface.rb +0 -106
- data/wordl-solver.gemspec +0 -59
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"word": "soare",
|
|
4
|
+
"score": 5.88596,
|
|
5
|
+
"is_possible_answer": false
|
|
6
|
+
},
|
|
7
|
+
{
|
|
8
|
+
"word": "roate",
|
|
9
|
+
"score": 5.882779,
|
|
10
|
+
"is_possible_answer": false
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"word": "raise",
|
|
14
|
+
"score": 5.87791,
|
|
15
|
+
"is_possible_answer": true
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"word": "raile",
|
|
19
|
+
"score": 5.86571,
|
|
20
|
+
"is_possible_answer": false
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"word": "reast",
|
|
24
|
+
"score": 5.865457,
|
|
25
|
+
"is_possible_answer": false
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"word": "slate",
|
|
29
|
+
"score": 5.855775,
|
|
30
|
+
"is_possible_answer": true
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"word": "crate",
|
|
34
|
+
"score": 5.834874,
|
|
35
|
+
"is_possible_answer": true
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"word": "salet",
|
|
39
|
+
"score": 5.834582,
|
|
40
|
+
"is_possible_answer": false
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"word": "irate",
|
|
44
|
+
"score": 5.831397,
|
|
45
|
+
"is_possible_answer": true
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"word": "trace",
|
|
49
|
+
"score": 5.830549,
|
|
50
|
+
"is_possible_answer": true
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"word": "arise",
|
|
54
|
+
"score": 5.82094,
|
|
55
|
+
"is_possible_answer": true
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"word": "orate",
|
|
59
|
+
"score": 5.817161,
|
|
60
|
+
"is_possible_answer": false
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"word": "stare",
|
|
64
|
+
"score": 5.80728,
|
|
65
|
+
"is_possible_answer": true
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"word": "carte",
|
|
69
|
+
"score": 5.794557,
|
|
70
|
+
"is_possible_answer": false
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"word": "raine",
|
|
74
|
+
"score": 5.78671,
|
|
75
|
+
"is_possible_answer": false
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"word": "caret",
|
|
79
|
+
"score": 5.776713,
|
|
80
|
+
"is_possible_answer": false
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"word": "ariel",
|
|
84
|
+
"score": 5.775167,
|
|
85
|
+
"is_possible_answer": false
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"word": "taler",
|
|
89
|
+
"score": 5.770612,
|
|
90
|
+
"is_possible_answer": false
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"word": "carle",
|
|
94
|
+
"score": 5.770479,
|
|
95
|
+
"is_possible_answer": false
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
"word": "slane",
|
|
99
|
+
"score": 5.770181,
|
|
100
|
+
"is_possible_answer": false
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
"word": "snare",
|
|
104
|
+
"score": 5.770089,
|
|
105
|
+
"is_possible_answer": true
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
"word": "artel",
|
|
109
|
+
"score": 5.768295,
|
|
110
|
+
"is_possible_answer": false
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
"word": "arose",
|
|
114
|
+
"score": 5.767797,
|
|
115
|
+
"is_possible_answer": true
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
"word": "strae",
|
|
119
|
+
"score": 5.767281,
|
|
120
|
+
"is_possible_answer": false
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
"word": "carse",
|
|
124
|
+
"score": 5.765364,
|
|
125
|
+
"is_possible_answer": false
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
"word": "saine",
|
|
129
|
+
"score": 5.764078,
|
|
130
|
+
"is_possible_answer": false
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
"word": "earst",
|
|
134
|
+
"score": 5.757081,
|
|
135
|
+
"is_possible_answer": false
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
"word": "taser",
|
|
139
|
+
"score": 5.752976,
|
|
140
|
+
"is_possible_answer": false
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
"word": "least",
|
|
144
|
+
"score": 5.751646,
|
|
145
|
+
"is_possible_answer": true
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
"word": "alert",
|
|
149
|
+
"score": 5.745837,
|
|
150
|
+
"is_possible_answer": true
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
"word": "crane",
|
|
154
|
+
"score": 5.742782,
|
|
155
|
+
"is_possible_answer": true
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
"word": "tares",
|
|
159
|
+
"score": 5.742548,
|
|
160
|
+
"is_possible_answer": false
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
"word": "seral",
|
|
164
|
+
"score": 5.739455,
|
|
165
|
+
"is_possible_answer": false
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
"word": "stale",
|
|
169
|
+
"score": 5.738573,
|
|
170
|
+
"is_possible_answer": true
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
"word": "saner",
|
|
174
|
+
"score": 5.733713,
|
|
175
|
+
"is_possible_answer": true
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
"word": "ratel",
|
|
179
|
+
"score": 5.73087,
|
|
180
|
+
"is_possible_answer": false
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
"word": "torse",
|
|
184
|
+
"score": 5.72338,
|
|
185
|
+
"is_possible_answer": false
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
"word": "tears",
|
|
189
|
+
"score": 5.718023,
|
|
190
|
+
"is_possible_answer": false
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
"word": "resat",
|
|
194
|
+
"score": 5.716071,
|
|
195
|
+
"is_possible_answer": false
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
"word": "alter",
|
|
199
|
+
"score": 5.713171,
|
|
200
|
+
"is_possible_answer": true
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
"word": "later",
|
|
204
|
+
"score": 5.706089,
|
|
205
|
+
"is_possible_answer": true
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
"word": "prate",
|
|
209
|
+
"score": 5.700636,
|
|
210
|
+
"is_possible_answer": false
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
"word": "trine",
|
|
214
|
+
"score": 5.697082,
|
|
215
|
+
"is_possible_answer": false
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
"word": "react",
|
|
219
|
+
"score": 5.696354,
|
|
220
|
+
"is_possible_answer": true
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
"word": "saice",
|
|
224
|
+
"score": 5.691244,
|
|
225
|
+
"is_possible_answer": false
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
"word": "toile",
|
|
229
|
+
"score": 5.691014,
|
|
230
|
+
"is_possible_answer": false
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
"word": "earnt",
|
|
234
|
+
"score": 5.690444,
|
|
235
|
+
"is_possible_answer": false
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
"word": "trone",
|
|
239
|
+
"score": 5.684949,
|
|
240
|
+
"is_possible_answer": false
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
"word": "leant",
|
|
244
|
+
"score": 5.684574,
|
|
245
|
+
"is_possible_answer": true
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
"word": "liane",
|
|
249
|
+
"score": 5.68423,
|
|
250
|
+
"is_possible_answer": false
|
|
251
|
+
}
|
|
252
|
+
]
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../wordl_solver"
|
|
5
|
+
require_relative "feedback"
|
|
6
|
+
require_relative "game_state"
|
|
7
|
+
require_relative "filter"
|
|
8
|
+
require_relative "scorer"
|
|
9
|
+
require_relative "word_lists"
|
|
10
|
+
|
|
11
|
+
module WordlSolver
|
|
12
|
+
class CLI
|
|
13
|
+
DEFAULT_TOP = 10
|
|
14
|
+
OPENING_SCORES_PATH = File.expand_path("../data/opening_scores.json", __dir__)
|
|
15
|
+
|
|
16
|
+
def self.run(argv = ARGV, input: $stdin, output: $stdout, error: $stderr)
|
|
17
|
+
top = parse_top(argv) || DEFAULT_TOP
|
|
18
|
+
new(input: input, output: output, error: error, top: top).run
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.parse_top(argv)
|
|
22
|
+
idx = argv.index("--top")
|
|
23
|
+
return nil unless idx
|
|
24
|
+
|
|
25
|
+
value = argv[idx + 1]
|
|
26
|
+
raise ArgumentError, "--top requires a positive integer argument" if value.nil?
|
|
27
|
+
|
|
28
|
+
parsed = Integer(value)
|
|
29
|
+
raise ArgumentError, "--top must be a positive integer, got #{value.inspect}" unless parsed.positive?
|
|
30
|
+
|
|
31
|
+
parsed
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def initialize(input:, output:, error:, top: DEFAULT_TOP)
|
|
35
|
+
@in = input
|
|
36
|
+
@out = output
|
|
37
|
+
@err = error
|
|
38
|
+
@top = top
|
|
39
|
+
@state = GameState.new
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def run
|
|
43
|
+
print_banner
|
|
44
|
+
loop do
|
|
45
|
+
candidates = current_candidates
|
|
46
|
+
print_turn_header(candidates)
|
|
47
|
+
print_suggestions(candidates)
|
|
48
|
+
return if @state.solved?
|
|
49
|
+
|
|
50
|
+
line = read_line
|
|
51
|
+
return if line.nil?
|
|
52
|
+
|
|
53
|
+
case line
|
|
54
|
+
when ":quit", ":q"
|
|
55
|
+
return
|
|
56
|
+
when ":help", ":h", ":?"
|
|
57
|
+
print_help
|
|
58
|
+
when ":undo", ":u"
|
|
59
|
+
handle_undo
|
|
60
|
+
when ":restart", ":r"
|
|
61
|
+
@state = GameState.new
|
|
62
|
+
else
|
|
63
|
+
handle_guess(line)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def print_banner
|
|
71
|
+
@out.puts "wordl-solver #{WordlSolver::VERSION}"
|
|
72
|
+
@out.puts "Type: <word> <pattern> (pattern is 5 chars from g/y/s)"
|
|
73
|
+
@out.puts "Commands: :undo :restart :quit :help"
|
|
74
|
+
@out.puts
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def print_help
|
|
78
|
+
@out.puts
|
|
79
|
+
@out.puts " <word> <pattern> submit a guess, e.g. crane sygss"
|
|
80
|
+
@out.puts " pattern chars: g=green, y=yellow, s=grey (b and . also accepted for grey)"
|
|
81
|
+
@out.puts " :undo remove the last turn"
|
|
82
|
+
@out.puts " :restart start over"
|
|
83
|
+
@out.puts " :quit exit"
|
|
84
|
+
@out.puts " :help this message"
|
|
85
|
+
@out.puts
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def print_turn_header(candidates)
|
|
89
|
+
turn_num = @state.turns.size + 1
|
|
90
|
+
@out.puts "Turn #{turn_num} — #{candidates.size} possible answer#{'s' unless candidates.size == 1}."
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def print_suggestions(candidates)
|
|
94
|
+
if @state.solved?
|
|
95
|
+
@out.puts " Solved! 🎉"
|
|
96
|
+
return
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
if candidates.empty?
|
|
100
|
+
@out.puts " No candidates remain. Did a guess get an incorrect pattern? Try :undo."
|
|
101
|
+
return
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
ranked = if @state.empty?
|
|
105
|
+
opening_scores
|
|
106
|
+
else
|
|
107
|
+
Scorer.rank(candidates: candidates, guess_pool: WordLists::ALLOWED_GUESSES)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
shown = ranked.first(@top)
|
|
111
|
+
possible = shown.select(&:is_possible_answer)
|
|
112
|
+
others = shown - possible
|
|
113
|
+
|
|
114
|
+
show_bits = candidates.size > 2
|
|
115
|
+
formatter = ->(sw) { show_bits ? format(" %-6s %.2f bits", sw.word, sw.score) : " #{sw.word}" }
|
|
116
|
+
|
|
117
|
+
if possible.any?
|
|
118
|
+
@out.puts " Possible answers:"
|
|
119
|
+
possible.each { |sw| @out.puts formatter.call(sw) }
|
|
120
|
+
end
|
|
121
|
+
if others.any?
|
|
122
|
+
@out.puts " High-info guesses:"
|
|
123
|
+
others.each { |sw| @out.puts "#{formatter.call(sw)} (not a possible answer)" }
|
|
124
|
+
end
|
|
125
|
+
@out.puts
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def current_candidates
|
|
129
|
+
Filter.candidates(WordLists::ANSWERS, @state)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def opening_scores
|
|
133
|
+
@opening_scores ||= JSON.parse(File.read(OPENING_SCORES_PATH)).map do |row|
|
|
134
|
+
Scorer::ScoredWord.new(row["word"], row["score"], row["is_possible_answer"])
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def read_line
|
|
139
|
+
@out.print "> "
|
|
140
|
+
@out.flush
|
|
141
|
+
line = @in.gets
|
|
142
|
+
return nil if line.nil?
|
|
143
|
+
|
|
144
|
+
line.strip
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def handle_undo
|
|
148
|
+
if @state.empty?
|
|
149
|
+
@out.puts " Nothing to undo."
|
|
150
|
+
else
|
|
151
|
+
@state = @state.undo
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def handle_guess(line)
|
|
156
|
+
parts = line.split(/\s+/, 2)
|
|
157
|
+
if parts.size != 2
|
|
158
|
+
@out.puts " Expected: <word> <pattern>. Try again or :help."
|
|
159
|
+
return
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
word, pattern = parts
|
|
163
|
+
word = word.downcase
|
|
164
|
+
|
|
165
|
+
unless Filter.valid_guess?(word)
|
|
166
|
+
@out.puts " #{word.inspect} is not a valid 5-letter word."
|
|
167
|
+
return
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
feedback = begin
|
|
171
|
+
Feedback.parse(pattern.downcase)
|
|
172
|
+
rescue ArgumentError => e
|
|
173
|
+
@out.puts " Bad pattern: #{e.message}"
|
|
174
|
+
return
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
@state = @state.apply(guess: word, feedback: feedback)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WordlSolver
|
|
4
|
+
class Feedback
|
|
5
|
+
VALID_COLORS = %i[green yellow grey].freeze
|
|
6
|
+
CHAR_TO_COLOR = {
|
|
7
|
+
"g" => :green,
|
|
8
|
+
"y" => :yellow,
|
|
9
|
+
"s" => :grey,
|
|
10
|
+
"b" => :grey,
|
|
11
|
+
"." => :grey
|
|
12
|
+
}.freeze
|
|
13
|
+
COLOR_TO_CHAR = { green: "g", yellow: "y", grey: "s" }.freeze
|
|
14
|
+
|
|
15
|
+
attr_reader :colors
|
|
16
|
+
|
|
17
|
+
def self.parse(string)
|
|
18
|
+
raise ArgumentError, "feedback must be 5 chars, got #{string.inspect}" unless string.length == 5
|
|
19
|
+
|
|
20
|
+
colors = string.chars.map do |ch|
|
|
21
|
+
CHAR_TO_COLOR[ch] || raise(ArgumentError, "invalid feedback char #{ch.inspect}")
|
|
22
|
+
end
|
|
23
|
+
new(colors)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(colors)
|
|
27
|
+
raise ArgumentError, "feedback must be 5 colors" unless colors.length == 5
|
|
28
|
+
|
|
29
|
+
colors.each do |c|
|
|
30
|
+
raise ArgumentError, "invalid color #{c.inspect}" unless VALID_COLORS.include?(c)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
@colors = colors.freeze
|
|
34
|
+
freeze
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def [](index)
|
|
38
|
+
@colors[index]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def all_green?
|
|
42
|
+
@colors.all? { |c| c == :green }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def to_s
|
|
46
|
+
@colors.map { |c| COLOR_TO_CHAR.fetch(c) }.join
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def ==(other)
|
|
50
|
+
other.is_a?(Feedback) && other.colors == @colors
|
|
51
|
+
end
|
|
52
|
+
alias eql? ==
|
|
53
|
+
|
|
54
|
+
def hash
|
|
55
|
+
@colors.hash
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "word_lists"
|
|
4
|
+
|
|
5
|
+
module WordlSolver
|
|
6
|
+
module Filter
|
|
7
|
+
# Pure function. Returns the subset of word_list consistent with every turn
|
|
8
|
+
# in game_state.
|
|
9
|
+
def self.candidates(word_list, game_state)
|
|
10
|
+
return word_list.dup if game_state.empty?
|
|
11
|
+
|
|
12
|
+
constraints = game_state.turns.map { |turn| compile_constraints(turn.guess, turn.feedback) }
|
|
13
|
+
|
|
14
|
+
word_list.select do |word|
|
|
15
|
+
constraints.all? { |c| satisfies?(word, c) }
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.valid_guess?(word)
|
|
20
|
+
WordLists::ALLOWED_GUESSES_SET.include?(word)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# --- internals --------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
# Compile one turn into a fast-checkable constraint bundle:
|
|
26
|
+
# greens: Hash{position => required_letter}
|
|
27
|
+
# yellows: Hash{position => forbidden_letter_at_this_position}
|
|
28
|
+
# counts: Hash{letter => [min_count, has_grey]}
|
|
29
|
+
def self.compile_constraints(guess, feedback)
|
|
30
|
+
greens = {}
|
|
31
|
+
yellows = {}
|
|
32
|
+
per_letter = Hash.new { |h, k| h[k] = { min: 0, grey: false } }
|
|
33
|
+
|
|
34
|
+
5.times do |i|
|
|
35
|
+
letter = guess[i]
|
|
36
|
+
case feedback[i]
|
|
37
|
+
when :green
|
|
38
|
+
greens[i] = letter
|
|
39
|
+
per_letter[letter][:min] += 1
|
|
40
|
+
when :yellow
|
|
41
|
+
yellows[i] = letter
|
|
42
|
+
per_letter[letter][:min] += 1
|
|
43
|
+
when :grey
|
|
44
|
+
per_letter[letter][:grey] = true
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
counts = per_letter.transform_values { |v| [v[:min], v[:grey]] }
|
|
49
|
+
{ greens: greens, yellows: yellows, counts: counts }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.satisfies?(word, c)
|
|
53
|
+
c[:greens].each { |i, ch| return false unless word[i] == ch }
|
|
54
|
+
c[:yellows].each { |i, ch| return false if word[i] == ch }
|
|
55
|
+
c[:counts].each do |letter, (min, has_grey)|
|
|
56
|
+
actual = word.count(letter)
|
|
57
|
+
if has_grey
|
|
58
|
+
return false unless actual == min
|
|
59
|
+
else
|
|
60
|
+
return false unless actual >= min
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
true
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "feedback"
|
|
4
|
+
|
|
5
|
+
module WordlSolver
|
|
6
|
+
Turn = Struct.new(:guess, :feedback) do
|
|
7
|
+
def initialize(guess, feedback)
|
|
8
|
+
super(guess.freeze, feedback)
|
|
9
|
+
freeze
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class GameState
|
|
14
|
+
attr_reader :turns
|
|
15
|
+
|
|
16
|
+
def initialize(turns = [])
|
|
17
|
+
@turns = turns.freeze
|
|
18
|
+
freeze
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def empty?
|
|
22
|
+
@turns.empty?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def solved?
|
|
26
|
+
!@turns.empty? && @turns.last.feedback.all_green?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def apply(guess:, feedback:)
|
|
30
|
+
GameState.new(@turns + [Turn.new(guess, feedback)])
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def undo
|
|
34
|
+
raise ArgumentError, "cannot undo: state is empty" if @turns.empty?
|
|
35
|
+
|
|
36
|
+
GameState.new(@turns[0...-1])
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
require_relative "../wordl_solver"
|
|
5
|
+
|
|
6
|
+
module WordlSolver
|
|
7
|
+
module Scorer
|
|
8
|
+
ScoredWord = Struct.new(:word, :score, :is_possible_answer)
|
|
9
|
+
|
|
10
|
+
# Returns an Array<ScoredWord> sorted by score descending.
|
|
11
|
+
# For each guess in the guess pool, computes Shannon entropy of the
|
|
12
|
+
# distribution of feedback patterns it would produce against `candidates`.
|
|
13
|
+
def self.rank(candidates:, guess_pool:)
|
|
14
|
+
return [] if candidates.empty?
|
|
15
|
+
|
|
16
|
+
candidate_set = candidates.to_set
|
|
17
|
+
if candidates.size <= 2
|
|
18
|
+
return candidates.map { |w| ScoredWord.new(w, 0.0, true) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
effective_pool = candidates.size <= 50 ? candidates : guess_pool
|
|
22
|
+
|
|
23
|
+
n = candidates.size.to_f
|
|
24
|
+
effective_pool.map do |guess|
|
|
25
|
+
counts = Hash.new(0)
|
|
26
|
+
candidates.each do |secret|
|
|
27
|
+
counts[WordlSolver.score(guess, secret)] += 1
|
|
28
|
+
end
|
|
29
|
+
entropy = counts.each_value.sum(0.0) do |c|
|
|
30
|
+
p = c / n
|
|
31
|
+
-p * Math.log2(p)
|
|
32
|
+
end
|
|
33
|
+
ScoredWord.new(guess, entropy, candidate_set.include?(guess))
|
|
34
|
+
end.sort_by { |sw| -sw.score }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module WordlSolver
|
|
6
|
+
module WordLists
|
|
7
|
+
DATA_DIR = File.expand_path("../data", __dir__)
|
|
8
|
+
|
|
9
|
+
ANSWERS = File.readlines(File.join(DATA_DIR, "answers.txt"), chomp: true).each(&:freeze).freeze
|
|
10
|
+
ALLOWED_GUESSES = File.readlines(File.join(DATA_DIR, "allowed_guesses.txt"), chomp: true).each(&:freeze).freeze
|
|
11
|
+
ALLOWED_GUESSES_SET = Set.new(ALLOWED_GUESSES).freeze
|
|
12
|
+
end
|
|
13
|
+
end
|