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.
@@ -0,0 +1,74 @@
1
+ require "set"
2
+
3
+ module RubyWordle
4
+ # Word lists for one word length.
5
+ #
6
+ # +answers+ are common words a game picks from; +guesses+ are every word
7
+ # accepted as a guess. Every answer is also a valid guess.
8
+ class Dictionary
9
+ DATA_DIR = File.expand_path("../../word-lists", __dir__)
10
+ WORD_LENGTH = 5
11
+
12
+ attr_reader :answers, :guesses, :length
13
+
14
+ def self.load(dir = File.join(DATA_DIR, "#{WORD_LENGTH}-letters"), length: WORD_LENGTH)
15
+ read = ->(name) { File.readlines(File.join(dir, name), chomp: true) }
16
+ new(answers: read.("word-list-basic.txt"), guesses: read.("word-list.txt"), length: length)
17
+ end
18
+
19
+ def self.default
20
+ @default ||= load
21
+ end
22
+
23
+ def initialize(answers:, guesses:, length: WORD_LENGTH)
24
+ @length = length
25
+ invalid = (answers + guesses).grep_v(/\A[a-z]{#{length}}\z/)
26
+ raise ArgumentError, "not #{length} letters a-z: #{invalid.first(3).join(', ')}" unless invalid.empty?
27
+ raise ArgumentError, "no answers" if answers.empty?
28
+
29
+ @answers = answers.uniq.sort.freeze
30
+ @guesses = (guesses | answers).sort.freeze
31
+ @answer_set = @answers.to_set
32
+ @guess_set = @guesses.to_set
33
+ end
34
+
35
+ def valid_guess?(word)
36
+ @guess_set.include?(word)
37
+ end
38
+
39
+ def common?(word)
40
+ @answer_set.include?(word)
41
+ end
42
+
43
+ # Keeps alphabetic words of +length+ letters, lowercased. Capitalized
44
+ # entries in a source list (proper nouns) are dropped when
45
+ # +lowercase_only+ is set.
46
+ def self.normalize(lines, length:, lowercase_only: false)
47
+ lines.map(&:strip)
48
+ .reject { |w| lowercase_only && w.match?(/\A[A-Z][a-z]/) }
49
+ .map(&:downcase)
50
+ .select { |w| w.match?(/\A[a-z]{#{length}}\z/) }
51
+ .uniq
52
+ .sort
53
+ end
54
+
55
+ # Writes the n-letter lists derived from the full and basic source lists.
56
+ def self.generate(length:, source_dir: DATA_DIR, out_dir: File.join(source_dir, "#{length}-letters"))
57
+ read = ->(name) { File.readlines(File.join(source_dir, name), chomp: true) }
58
+ basic = normalize(read.("word-list-basic.txt"), length: length, lowercase_only: true)
59
+ all = (normalize(read.("word-list-original.txt"), length: length) | basic).sort
60
+ uniq = ->(words) { words.select { |w| w.chars.uniq.size == length } }
61
+
62
+ Dir.mkdir(out_dir) unless Dir.exist?(out_dir)
63
+ {
64
+ "word-list.txt" => all,
65
+ "word-list-uniq-letters.txt" => uniq.(all),
66
+ "word-list-basic.txt" => basic,
67
+ "word-list-basic-uniq-letters.txt" => uniq.(basic)
68
+ }.each do |name, words|
69
+ File.write(File.join(out_dir, name), words.map { |w| "#{w}\n" }.join)
70
+ end
71
+ out_dir
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,73 @@
1
+ module RubyWordle
2
+ # Wordle feedback for one guess against one answer.
3
+ #
4
+ # A pattern is an Integer in base 3, one digit per position (position 0 is
5
+ # the least significant digit): 0 = absent, 1 = present elsewhere,
6
+ # 2 = correct. Integers keep the solver's partitioning cheap.
7
+ module Feedback
8
+ ABSENT = 0
9
+ PRESENT = 1
10
+ CORRECT = 2
11
+
12
+ SYMBOLS = { "x" => ABSENT, "b" => ABSENT, "0" => ABSENT, "-" => ABSENT, "." => ABSENT,
13
+ "y" => PRESENT, "1" => PRESENT,
14
+ "g" => CORRECT, "2" => CORRECT }.freeze
15
+
16
+ module_function
17
+
18
+ def score(guess, answer)
19
+ size = guess.bytesize
20
+ marks = Array.new(size, ABSENT)
21
+ remaining = Hash.new(0)
22
+
23
+ size.times do |i|
24
+ if guess.getbyte(i) == answer.getbyte(i)
25
+ marks[i] = CORRECT
26
+ else
27
+ remaining[answer.getbyte(i)] += 1
28
+ end
29
+ end
30
+
31
+ size.times do |i|
32
+ next if marks[i] == CORRECT
33
+
34
+ byte = guess.getbyte(i)
35
+ if remaining[byte] > 0
36
+ marks[i] = PRESENT
37
+ remaining[byte] -= 1
38
+ end
39
+ end
40
+
41
+ encode(marks)
42
+ end
43
+
44
+ def encode(marks)
45
+ marks.reverse_each.reduce(0) { |acc, mark| acc * 3 + mark }
46
+ end
47
+
48
+ def decode(pattern, size)
49
+ Array.new(size) do
50
+ mark = pattern % 3
51
+ pattern /= 3
52
+ mark
53
+ end
54
+ end
55
+
56
+ def solved(size)
57
+ encode(Array.new(size, CORRECT))
58
+ end
59
+
60
+ # Parses user notation such as "xygxx", "01200" or "b.y-g".
61
+ # Returns nil when the text is not a valid pattern of the given size.
62
+ def parse(text, size)
63
+ chars = text.downcase.chars
64
+ return nil unless chars.size == size && chars.all? { |c| SYMBOLS.key?(c) }
65
+
66
+ encode(chars.map { |c| SYMBOLS[c] })
67
+ end
68
+
69
+ def notation(pattern, size)
70
+ decode(pattern, size).map { |mark| "xyg"[mark] }.join
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,84 @@
1
+ require "ruby-wordle/feedback"
2
+
3
+ module RubyWordle
4
+ # One round of Wordle with a hidden answer.
5
+ class Game
6
+ class InvalidGuess < StandardError; end
7
+
8
+ MAX_ATTEMPTS = 6
9
+
10
+ attr_reader :answer, :history, :max_attempts, :dictionary, :revealed
11
+
12
+ def initialize(dictionary, answer: nil, random: Random.new, max_attempts: MAX_ATTEMPTS)
13
+ @dictionary = dictionary
14
+ @answer = answer || dictionary.answers.sample(random: random)
15
+ raise ArgumentError, "#{@answer} is not in the word list" unless dictionary.valid_guess?(@answer)
16
+
17
+ @max_attempts = max_attempts
18
+ @history = []
19
+ @assisted = false
20
+ @revealed = nil
21
+ end
22
+
23
+ # Marks that the player used a hint.
24
+ def assist!
25
+ @assisted = true
26
+ end
27
+
28
+ def assisted?
29
+ @assisted
30
+ end
31
+
32
+ # Reveals the letter at the first position never guessed correctly, once
33
+ # per game, as [position, letter]. Returns nil when fewer than two
34
+ # positions have never been guessed correctly.
35
+ def reveal
36
+ return @revealed if @revealed
37
+
38
+ found = @history.flat_map do |_, pattern|
39
+ marks = Feedback.decode(pattern, length)
40
+ marks.each_index.select { |i| marks[i] == Feedback::CORRECT }
41
+ end
42
+ unknown = (0...length).to_a - found
43
+ return nil if unknown.size < 2
44
+
45
+ assist!
46
+ @revealed = [unknown.first, @answer[unknown.first]]
47
+ end
48
+
49
+ def length
50
+ @answer.length
51
+ end
52
+
53
+ def guess(word)
54
+ raise InvalidGuess, "the game is over" if over?
55
+ raise InvalidGuess, "type #{length} letters (a-z)" unless word.match?(/\A[a-z]{#{length}}\z/)
56
+ raise InvalidGuess, "\"#{word}\" is not in the word list" unless @dictionary.valid_guess?(word)
57
+
58
+ pattern = Feedback.score(word, @answer)
59
+ @history << [word, pattern]
60
+ pattern
61
+ end
62
+
63
+ def won?
64
+ @history.any? { |_, pattern| pattern == Feedback.solved(length) }
65
+ end
66
+
67
+ def over?
68
+ won? || @history.size >= @max_attempts
69
+ end
70
+
71
+ def attempts_left
72
+ @max_attempts - @history.size
73
+ end
74
+
75
+ # The best feedback seen for each guessed letter, for the keyboard.
76
+ def letter_states
77
+ @history.each_with_object({}) do |(word, pattern), states|
78
+ word.chars.zip(Feedback.decode(pattern, length)) do |char, mark|
79
+ states[char] = mark if states.fetch(char, -1) < mark
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,92 @@
1
+ require "ruby-wordle/feedback"
2
+
3
+ module RubyWordle
4
+ # Narrows down the words consistent with a history of guesses and
5
+ # feedback, and ranks the next guess.
6
+ #
7
+ # A word stays a candidate only if scoring it as the answer reproduces
8
+ # every observed pattern, so repeated letters are handled exactly as the
9
+ # game scores them.
10
+ class Solver
11
+ Suggestion = Struct.new(:word, :expected, :possible)
12
+
13
+ SAMPLE_SIZE = 400
14
+ PROBE_SIZE = 300
15
+
16
+ attr_reader :dictionary, :history, :candidates
17
+
18
+ def initialize(dictionary, pool: dictionary.guesses)
19
+ @dictionary = dictionary
20
+ @pool = pool
21
+ reset
22
+ end
23
+
24
+ def reset
25
+ @history = []
26
+ @candidates = @pool
27
+ self
28
+ end
29
+
30
+ def add(guess, pattern)
31
+ guess = guess.dup.freeze
32
+ @history << [guess, pattern]
33
+ @candidates = @candidates.select { |word| Feedback.score(guess, word) == pattern }
34
+ self
35
+ end
36
+
37
+ def undo
38
+ previous = @history[0...-1]
39
+ reset
40
+ previous.each { |guess, pattern| add(guess, pattern) }
41
+ self
42
+ end
43
+
44
+ # Candidates that are common words; the answer is most likely among them.
45
+ def likely
46
+ common = @candidates.select { |w| @dictionary.common?(w) }
47
+ common.empty? ? @candidates : common
48
+ end
49
+
50
+ # Ranks guesses by the expected number of likely candidates left after
51
+ # playing them (lower is better), preferring words that can still be
52
+ # the answer on ties. Large sets are estimated from an even sample.
53
+ def suggest(limit = 10)
54
+ targets = likely
55
+ return [] if targets.empty?
56
+
57
+ sample = spread(targets, SAMPLE_SIZE)
58
+ scale = targets.size.to_f / (sample.size**2)
59
+ possible = @candidates.to_h { |w| [w, true] }
60
+
61
+ ranked = probes(targets).map do |probe|
62
+ buckets = Hash.new(0)
63
+ sample.each { |answer| buckets[Feedback.score(probe, answer)] += 1 }
64
+ expected = buckets.each_value.sum { |n| n * n } * scale
65
+ Suggestion.new(probe, expected, possible.key?(probe))
66
+ end
67
+
68
+ ranked.min_by(limit) { |s| [s.expected, s.possible ? 0 : 1, s.word] }
69
+ end
70
+
71
+ private
72
+
73
+ def spread(words, size)
74
+ return words if words.size <= size
75
+
76
+ Array.new(size) { |i| words[i * words.size / size] }
77
+ end
78
+
79
+ # The targets themselves plus guesses whose letters split the targets
80
+ # most evenly, which can narrow them down better than any target can.
81
+ def probes(targets)
82
+ frequency = Hash.new(0)
83
+ targets.each { |w| w.chars.uniq.each { |c| frequency[c] += 1 } }
84
+ split = frequency.transform_values { |n| n * (targets.size - n) }
85
+ coverage = ->(w) { -w.chars.uniq.sum { |c| split.fetch(c, 0) } }
86
+
87
+ own = targets.size <= PROBE_SIZE ? targets : targets.min_by(PROBE_SIZE, &coverage)
88
+ others = @dictionary.guesses.min_by(PROBE_SIZE / 2, &coverage)
89
+ own | others
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,56 @@
1
+ require "ruby-wordle/feedback"
2
+
3
+ module RubyWordle
4
+ # Terminal styling. With color disabled, marks are shown with brackets:
5
+ # [A] correct, (A) present elsewhere, and a plain lowercase letter absent.
6
+ class Style
7
+ CODES = { bold: 1, dim: 2, underline: 4, red: 31, green: 32, yellow: 33,
8
+ blue: 34, magenta: 35, cyan: 36, gray: 90 }.freeze
9
+ # Fixed 256-color tiles (green, yellow, gray with white letters) look the
10
+ # same in every terminal theme.
11
+ TILE_CODES = { Feedback::CORRECT => "1;38;5;231;48;5;71", Feedback::PRESENT => "1;38;5;231;48;5;178",
12
+ Feedback::ABSENT => "1;38;5;231;48;5;240" }.freeze
13
+ EMOJI = { Feedback::CORRECT => "🟩", Feedback::PRESENT => "🟨", Feedback::ABSENT => "⬛" }.freeze
14
+
15
+ def self.for(output, env = ENV)
16
+ new(color: output.respond_to?(:tty?) && output.tty? && env["NO_COLOR"].to_s.empty?)
17
+ end
18
+
19
+ def initialize(color:)
20
+ @color = color
21
+ end
22
+
23
+ def color?
24
+ @color
25
+ end
26
+
27
+ def paint(text, *styles)
28
+ return text.to_s unless @color && !styles.empty?
29
+
30
+ "\e[#{styles.map { |s| CODES.fetch(s) }.join(';')}m#{text}\e[0m"
31
+ end
32
+
33
+ # A letter tile; +mark+ is a Feedback mark, or nil for an untested letter.
34
+ def tile(char, mark)
35
+ if @color
36
+ mark ? "\e[#{TILE_CODES.fetch(mark)}m #{char.upcase} \e[0m" : " #{char.upcase} "
37
+ else
38
+ case mark
39
+ when Feedback::CORRECT then "[#{char.upcase}]"
40
+ when Feedback::PRESENT then "(#{char.upcase})"
41
+ when Feedback::ABSENT then " #{char.downcase} "
42
+ else " #{char.upcase} "
43
+ end
44
+ end
45
+ end
46
+
47
+ def row(word, pattern)
48
+ marks = Feedback.decode(pattern, word.length)
49
+ word.chars.zip(marks).map { |char, mark| tile(char, mark) }.join(" ")
50
+ end
51
+
52
+ def emoji(pattern, length)
53
+ Feedback.decode(pattern, length).map { |mark| EMOJI[mark] }.join
54
+ end
55
+ end
56
+ end
@@ -1,3 +1,3 @@
1
1
  module RubyWordle
2
- VERSION = "0.9.2"
2
+ VERSION = "1.0.0"
3
3
  end
@@ -0,0 +1,7 @@
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
+ require "ruby-wordle/cli"
@@ -1,9 +1,3 @@
1
- april
2
- bible
3
- false
4
- islam
5
- march
6
- x-ray
7
1
  abhor
8
2
  abide
9
3
  abort
@@ -333,6 +327,7 @@ facet
333
327
  faint
334
328
  fairy
335
329
  faith
330
+ false
336
331
  fancy
337
332
  farce
338
333
  fault
@@ -523,7 +518,6 @@ leafy
523
518
  learn
524
519
  least
525
520
  lemon
526
- let's
527
521
  light
528
522
  liken
529
523
  liner
@@ -1107,7 +1101,6 @@ wring
1107
1101
  wrist
1108
1102
  write
1109
1103
  wrong
1110
- x-ray
1111
1104
  yacht
1112
1105
  yearn
1113
1106
  yeast
@@ -1,10 +1,3 @@
1
- april
2
- bible
3
- false
4
- islam
5
- march
6
- satan
7
- x-ray
8
1
  abate
9
2
  abbey
10
3
  abhor
@@ -501,6 +494,7 @@ facet
501
494
  faint
502
495
  fairy
503
496
  faith
497
+ false
504
498
  fancy
505
499
  farce
506
500
  fatal
@@ -776,7 +770,6 @@ ledge
776
770
  leech
777
771
  legal
778
772
  lemon
779
- let's
780
773
  level
781
774
  lever
782
775
  libel
@@ -1592,7 +1585,6 @@ wring
1592
1585
  wrist
1593
1586
  write
1594
1587
  wrong
1595
- x-ray
1596
1588
  yacht
1597
1589
  yearn
1598
1590
  yeast
@@ -4772,6 +4772,7 @@ kilty
4772
4772
  kimbo
4773
4773
  kinah
4774
4774
  kinch
4775
+ kinda
4775
4776
  kinds
4776
4777
  kines
4777
4778
  kings
@@ -7526,6 +7526,7 @@ kimbo
7526
7526
  kimmo
7527
7527
  kinah
7528
7528
  kinch
7529
+ kinda
7529
7530
  kinds
7530
7531
  kines
7531
7532
  kings
metadata CHANGED
@@ -1,31 +1,30 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby-wordle
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.2
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yoichiro Hasebe
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2022-02-01 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
- name: readline
13
+ name: reline
15
14
  requirement: !ruby/object:Gem::Requirement
16
15
  requirements:
17
- - - ">="
16
+ - - "~>"
18
17
  - !ruby/object:Gem::Version
19
- version: '0'
18
+ version: '0.3'
20
19
  type: :runtime
21
20
  prerelease: false
22
21
  version_requirements: !ruby/object:Gem::Requirement
23
22
  requirements:
24
- - - ">="
23
+ - - "~>"
25
24
  - !ruby/object:Gem::Version
26
- version: '0'
27
- description: A set of ruby scripts to generate word-lists, solve Wordle, and play
28
- Wordle
25
+ version: '0.3'
26
+ description: A command-line Wordle game and a solver that ranks the next guess, with
27
+ English word lists.
29
28
  email:
30
29
  - yohasebe@gmail.com
31
30
  executables:
@@ -33,31 +32,29 @@ executables:
33
32
  extensions: []
34
33
  extra_rdoc_files: []
35
34
  files:
36
- - ".gitignore"
37
- - Gemfile
35
+ - CHANGELOG.md
38
36
  - LICENSE
39
37
  - README.md
40
- - Rakefile
41
38
  - bin/ruby-wordle
42
- - img/ruby-wordle-play.png
43
- - img/ruby-wordle-solver.png
44
- - lib/ruby-wordle/script-filter.rb
45
- - lib/ruby-wordle/script-play.rb
46
- - lib/ruby-wordle/script-solver.rb
47
- - lib/ruby-wordle/script-utils.rb
39
+ - lib/ruby-wordle.rb
40
+ - lib/ruby-wordle/cli.rb
41
+ - lib/ruby-wordle/dictionary.rb
42
+ - lib/ruby-wordle/feedback.rb
43
+ - lib/ruby-wordle/game.rb
44
+ - lib/ruby-wordle/solver.rb
45
+ - lib/ruby-wordle/style.rb
48
46
  - lib/ruby-wordle/version.rb
49
- - ruby-wordle.gemspec
50
47
  - word-lists/5-letters/word-list-basic-uniq-letters.txt
51
48
  - word-lists/5-letters/word-list-basic.txt
52
49
  - word-lists/5-letters/word-list-uniq-letters.txt
53
50
  - word-lists/5-letters/word-list.txt
54
- - word-lists/word-list-basic.txt
55
- - word-lists/word-list-original.txt
56
- homepage: http://github.com/yohasebe/ruby-wordle
51
+ homepage: https://github.com/yohasebe/ruby-wordle
57
52
  licenses:
58
53
  - MIT
59
- metadata: {}
60
- post_install_message:
54
+ metadata:
55
+ source_code_uri: https://github.com/yohasebe/ruby-wordle
56
+ changelog_uri: https://github.com/yohasebe/ruby-wordle/blob/main/CHANGELOG.md
57
+ rubygems_mfa_required: 'true'
61
58
  rdoc_options: []
62
59
  require_paths:
63
60
  - lib
@@ -65,15 +62,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
65
62
  requirements:
66
63
  - - ">="
67
64
  - !ruby/object:Gem::Version
68
- version: '0'
65
+ version: '3.2'
69
66
  required_rubygems_version: !ruby/object:Gem::Requirement
70
67
  requirements:
71
68
  - - ">="
72
69
  - !ruby/object:Gem::Version
73
70
  version: '0'
74
71
  requirements: []
75
- rubygems_version: 3.3.3
76
- signing_key:
72
+ rubygems_version: 3.6.9
77
73
  specification_version: 4
78
- summary: A set of ruby scripts to generate word-lists, solve Wordle, and play Wordle
74
+ summary: Play Wordle and solve Wordle on the command line
79
75
  test_files: []
data/.gitignore DELETED
@@ -1,58 +0,0 @@
1
- *.gem
2
- *.rbc
3
- /.config
4
- /coverage/
5
- /InstalledFiles
6
- /pkg/
7
- /spec/reports/
8
- /spec/examples.txt
9
- /test/tmp/
10
- /test/version_tmp/
11
- /tmp/
12
-
13
- # Used by dotenv library to load environment variables.
14
- # .env
15
-
16
- # Ignore Byebug command history file.
17
- .byebug_history
18
-
19
- ## Specific to RubyMotion:
20
- .dat*
21
- .repl_history
22
- build/
23
- *.bridgesupport
24
- build-iPhoneOS/
25
- build-iPhoneSimulator/
26
-
27
- ## Specific to RubyMotion (use of CocoaPods):
28
- #
29
- # We recommend against adding the Pods directory to your .gitignore. However
30
- # you should judge for yourself, the pros and cons are mentioned at:
31
- # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
32
- #
33
- # vendor/Pods/
34
-
35
- ## Documentation cache and generated files:
36
- /.yardoc/
37
- /_yardoc/
38
- /doc/
39
- /rdoc/
40
-
41
- ## Environment normalization:
42
- /.bundle/
43
- /vendor/bundle
44
- /lib/bundler/man/
45
-
46
- # for a library or gem, you might want to ignore these files since the code is
47
- # intended to run in multiple environments; otherwise, check them in:
48
- # Gemfile.lock
49
- # .ruby-version
50
- # .ruby-gemset
51
-
52
- # unless supporting rvm < 1.11.0 or doing something fancy, ignore this:
53
- .rvmrc
54
-
55
- # Used by RuboCop. Remote config files pulled in from inherit_from directive.
56
- # .rubocop-https?--*
57
-
58
- .DS_Store
data/Gemfile DELETED
@@ -1,3 +0,0 @@
1
- source "http://rubygems.org"
2
-
3
- gem 'readline'
data/Rakefile DELETED
@@ -1 +0,0 @@
1
- require "bundler/gem_tasks"
Binary file
Binary file