-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathwordle.rb
361 lines (294 loc) · 9.45 KB
/
wordle.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
require_relative 'word_matcher'
# rubocop:disable ClassLength
class Wordle
MAX_LENGTH = 5
attr_reader :guesses, :possible_answers
def initialize(opts = {})
@options = opts
@guesses = 0
@found = false
@broke = false
set_word_lists
end
def top_rated_word
rate_words
@guesses += 1
check_breakage
@current_guess = (@possibilities.min_by { |_, v| -v })[0]
end
def top_ten_words
rate_words
@possibilities.sort_by { |_, v| -v }.first(10).to_h.keys
end
def found?
@found || @broke
end
def broke?
@broke
end
def guess(word)
@current_guess = word
end
def finding_letters?
finding_unique_letters?
end
# rubocop:disable MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
def parse_answer(answer)
answer = answer.downcase
if answer == 'xxxxx'
puts "Removing word from list isn't implemented, sorry!"
exit!
end
if answer == 'yyyyy'
puts 'Congrats!! Found the word!!' unless quiet?
@found = true
end
# parse over first time to create counts of found letters
parse_found_letters(answer)
# parse again in order to handle N letters
parse_letter_counts(answer)
word_matcher.refresh_regex_pattern
# Keep possible answers clean
@possible_answers.each do |word|
@possible_answers -= [word] unless word_matcher.word_eligible?(word)
end
hidden_known_letters if find_hidden_letters?
return unless debug?
puts "Pattern: #{word_matcher.regex_pattern}"
# puts "Letter Counts: #{word_matcher.letter_counts}"
end
private
def word_matcher
@word_matcher ||= WordMatch.new(@options)
end
# Use positional distributions and word scoring.
# If true, the letter order matters. Will favor s in the first letter, for example
# If false, letter order does not matter. Will only favor most used letters.
def positional_ratings?
return true if @needed_letters.nil?
return false if @needed_letters.empty?
!finding_unique_letters?
end
# If the goal is to find unique letters
# This is set when trying to distinguish between hatch, match, and catch
def finding_unique_letters?
return false if @needed_letters.nil? || @needed_letters.empty?
return false if @possible_answers.length <= guess_when_words_remain
return true if word_matcher.found_letters_count >= hunt_letters
false
end
def word_list
return @guess_word_list if finding_unique_letters?
@possible_answers
end
# When looking at word list possibilities, exclude words that are ineligible
# When true:
# Small avg count goes from 4 to 3.5, no change on failed counts
# Full avg count from 3.75 to 3.6, failure from 17 to 14
def limit_distribution_to_eligible_words
true
end
# Which letters are still unknown?
def needed_letters
if @positional_ratings
flatten_positional.select { |_letter, count| count.positive? }
else
@distribution.select { |_letter, count| count.positive? }
end
end
# Converts a positional distribution to a flat distribution
def flatten_positional
flat_distribution = empty_distribution
(0..MAX_LENGTH - 1).each do |i|
@distribution[i.to_s].each do |letter, count|
flat_distribution[letter] += count
end
end
flat_distribution
end
# Did something break? If so, print some debug information
def check_breakage
return unless @guesses > 8
puts 'Something broke. Sorry bro.'
puts " Regex: #{@regex_pattern}"
@broke = true
end
# This option reduced speed by 10% and didn't improve counts
def find_hidden_letters?
false
end
def quiet?
@options[:quiet] || false
end
def debug?
@options[:debug] || @guesses > 6
end
# Switch to hunting unique letters at this many found letters
def hunt_letters
@options[:hunt_letters] || 3
end
# Switch to random guessing when this many words remain
def guess_when_words_remain
@options[:guess_count] || 2
end
# Return a distribution of letters that are still possible
# Given the word ?atch, this should return: p m w (for patch, match, watch). h and c (hatch and catch) shouldn't be
# returned because those letters were already found
def possible_letters
puts 'Going into LETTER HUNTER MODE!' if debug?
possible_letters = empty_distribution
@possible_answers.each do |word|
word_to_hash(word).each do |index, letter|
next if word_matcher.found_letters[index]
next unless word_matcher.letter_counts[letter][:max].nil?
# This will return an empty result when all letters are known, but a repeat letter needs to be found
next unless word_matcher.letter_counts[letter][:min].nil?
possible_letters[letter] += 1
end
end
puts "Need to rule out: #{possible_letters.select { |_letter, count| count.positive? }}" if debug?
possible_letters
end
# Perform actions on the Y and M letters in a guess
def parse_found_letters(answer)
@green_letters = []
@yellow_letters = []
(0..4).each do |i|
letter = @current_guess[i]
case answer[i]
when 'y'
word_matcher.set_found_letter(letter, i)
@green_letters.push(letter)
when 'm'
word_matcher.set_maybe_letter(letter, i)
@yellow_letters.push(letter)
end
end
end
# Calculate how many of each letter are possible in the word
def parse_letter_counts(answer)
(0..4).each do |i|
letter = @current_guess[i]
count = @green_letters.count(letter) + @yellow_letters.count(letter)
case answer[i]
when 'n'
if count.zero?
word_matcher.set_excluded_letter(letter)
else
word_matcher.set_max_letter_count(letter, count)
end
else
word_matcher.set_min_letter_count(letter, count)
end
end
end
# Look through remaining words and see if there are any letters that exist for every word
# Given hatch and cards, it should find that A needs to be in second position
def hidden_known_letters
return if @possible_answers.length == 1
first_word = @possible_answers[0]
letter_hash = word_to_hash(first_word)
# Only look at unknown letters
(0..4).each do |i|
letter_hash.delete(i) if word_matcher.found_letters[i]
end
# Loop through remaining words
@possible_answers.each do |word|
break if letter_hash.empty?
letter_hash.each do |i, value|
letter_hash.delete(i) if word[i] != value
end
end
return if letter_hash.empty?
# Found some - add them to known letters!
letter_hash.each do |i, letter|
word_matcher.set_found_letter(letter, i)
end
end
# Look over possible guesses, and rates them according to the given distribution
def rate_words
word_matcher.refresh_regex_pattern
word_matcher.refresh_found_letters_count
@positional_ratings = positional_ratings?
create_distribution
@possibilities = {}
@needed_letters = needed_letters
# Try to rule out remaining letters
word_list.each do |word|
@possibilities[word] = rate_word(word)
end
end
def rate_word(word)
rating = 0
seen_letters = []
word_to_hash(word).each do |index, letter|
# If not using positional ratings, don't count duplicate letters
next if !@positional_ratings && seen_letters.include?(letter)
# If finding unique letters, skip any letter that's known
if finding_unique_letters?
next if word_matcher.maybe_letters[index].include?(letter)
next unless word_matcher.letter_counts[letter][:max].nil?
next if seen_letters.include?(letter)
end
rating += @positional_ratings ? @distribution[index.to_s][letter] : @distribution[letter]
seen_letters.push(letter)
end
rating
end
def distribution_by_letter(word)
word_to_hash(word).each do |index, letter|
if @positional_ratings
@distribution[index.to_s][letter] += 1
else
next if word_matcher.found_letters[index]
@distribution[letter] += 1
end
end
end
# Creates a map of how likely letters are to be at a certain position
# IE, given words cat and cow:
# c is 2 likely to be at position 0
def create_distribution
if finding_unique_letters?
@distribution = possible_letters
return
end
@distribution = @positional_ratings ? empty_positional_distribution : empty_distribution
@possible_answers.each do |word|
next if limit_distribution_to_eligible_words && word_matcher.word_disqualified?(word)
distribution_by_letter(word)
end
end
# Holds letters and counts of those letters
def empty_distribution
distribution = {}
('a'..'z').each do |letter|
distribution[letter] = 0
end
distribution
end
# Holds a map of character positions with character counts in it
def empty_positional_distribution
positional_distribution = {}
(0..MAX_LENGTH - 1).each do |position|
positional_distribution[position.to_s] = empty_distribution
end
positional_distribution
end
# Returns word as a hash with index as key and letter as value
def word_to_hash(word)
i = 0
this_hash = {}
word.each_char do |letter|
this_hash[i] = letter
i += 1
end
this_hash
end
def set_word_lists
@possible_answers = File.read('possible_answers.txt').split
@guess_word_list = File.read('possible_answers.txt').split
@guess_word_list.concat(File.read('guess_word_list.txt').split)
@guess_word_list.uniq!
end
end