├── .gitignore ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── board.rb ├── botgle.rb ├── game.rb ├── games └── .gitkeep ├── generate-dictionary.rb ├── generate-words.sh ├── manager.rb ├── play.rb ├── season.rb ├── seasons └── .gitkeep ├── solver.rb ├── stats.rb ├── trie.rb ├── utils.rb └── words.dict /.gitignore: -------------------------------------------------------------------------------- 1 | *.json 2 | *.yml 3 | *~ 4 | 5 | *.gem 6 | *.rbc 7 | /.config 8 | /coverage/ 9 | /InstalledFiles 10 | /pkg/ 11 | /spec/reports/ 12 | /test/tmp/ 13 | /test/version_tmp/ 14 | /tmp/ 15 | 16 | ## Specific to RubyMotion: 17 | .dat* 18 | .repl_history 19 | build/ 20 | 21 | ## Documentation cache and generated files: 22 | /.yardoc/ 23 | /_yardoc/ 24 | /doc/ 25 | /rdoc/ 26 | 27 | ## Environment normalisation: 28 | /.bundle/ 29 | /vendor/bundle 30 | /lib/bundler/man/ 31 | 32 | # for a library or gem, you might want to ignore these files since the code is 33 | # intended to run in multiple environments; otherwise, check them in: 34 | # Gemfile.lock 35 | # .ruby-version 36 | # .ruby-gemset 37 | 38 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 39 | .rvmrc 40 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gem "chatterbot", git:"git://github.com/muffinista/chatterbot.git" 3 | gem "algorithms" 4 | gem "oj" 5 | gem "twitter-text" 6 | gem "aws-sdk" 7 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GIT 2 | remote: git://github.com/muffinista/chatterbot.git 3 | revision: 39f222494a46666cfbf20cef871b8b324f8ddafb 4 | specs: 5 | chatterbot (2.0.0.pre) 6 | colorize (>= 0.7.3) 7 | launchy (>= 2.4.2) 8 | oauth (>= 0.4.7) 9 | twitter (= 5.14.0) 10 | 11 | GEM 12 | remote: https://rubygems.org/ 13 | specs: 14 | addressable (2.3.8) 15 | algorithms (0.6.1) 16 | aws-sdk (2.1.14) 17 | aws-sdk-resources (= 2.1.14) 18 | aws-sdk-core (2.1.14) 19 | jmespath (~> 1.0) 20 | aws-sdk-resources (2.1.14) 21 | aws-sdk-core (= 2.1.14) 22 | buftok (0.2.0) 23 | colorize (0.7.7) 24 | equalizer (0.0.11) 25 | faraday (0.9.1) 26 | multipart-post (>= 1.2, < 3) 27 | http (0.6.4) 28 | http_parser.rb (~> 0.6.0) 29 | http_parser.rb (0.6.0) 30 | jmespath (1.0.2) 31 | multi_json (~> 1.0) 32 | json (1.8.3) 33 | launchy (2.4.3) 34 | addressable (~> 2.3) 35 | memoizable (0.4.2) 36 | thread_safe (~> 0.3, >= 0.3.1) 37 | multi_json (1.11.2) 38 | multipart-post (2.0.0) 39 | naught (1.0.0) 40 | oauth (0.4.7) 41 | oj (2.12.4) 42 | simple_oauth (0.3.1) 43 | thread_safe (0.3.5) 44 | twitter (5.14.0) 45 | addressable (~> 2.3) 46 | buftok (~> 0.2.0) 47 | equalizer (~> 0.0.9) 48 | faraday (~> 0.9.0) 49 | http (~> 0.6.0) 50 | http_parser.rb (~> 0.6.0) 51 | json (~> 1.8) 52 | memoizable (~> 0.4.0) 53 | naught (~> 1.0) 54 | simple_oauth (~> 0.3.0) 55 | twitter-text (1.12.0) 56 | unf (~> 0.1.0) 57 | unf (0.1.4) 58 | unf_ext 59 | unf_ext (0.0.7.1) 60 | 61 | PLATFORMS 62 | ruby 63 | 64 | DEPENDENCIES 65 | algorithms 66 | aws-sdk 67 | chatterbot! 68 | oj 69 | twitter-text 70 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Colin Mitchell 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /board.rb: -------------------------------------------------------------------------------- 1 | 2 | # 3 | # https://github.com/scheibo/boggle/blob/master/lib/boggle/board.rb 4 | # 5 | class Board 6 | attr_reader :size 7 | attr_reader :letters 8 | 9 | def initialize(opts = {}) 10 | opts = {dictionary:"words.dict", variant:0, size:4}.merge(opts) 11 | 12 | @size = opts[:size] 13 | 14 | if opts[:letters] 15 | @letters = opts[:letters] 16 | else 17 | @letters = Board.distributions(@size, opts[:variant]).sort_by{ rand }.map { |d| d.sample } 18 | 19 | # prevent 5x5 boards with Qu in them since we can't display it reliably on small displays 20 | while @size > 4 && letters.include?("Qu") 21 | @letters = Board.distributions(@size, opts[:variant]).sort_by{ rand }.map { |d| d.sample } 22 | end 23 | end 24 | 25 | @board = [] 26 | tmp = @letters.dup 27 | @size.times do 28 | @board << tmp.pop(@size) 29 | end 30 | end 31 | 32 | def [](row, col) 33 | ( (row < 0) || (col < 0) || (row >= @size) || (col >= @size) ) ? nil : @board[row][col] 34 | end 35 | 36 | # deepcopy first 37 | def []=(row, col, val) 38 | @board[row][col]=val 39 | end 40 | 41 | def deepcopy 42 | Marshal.load( Marshal.dump(self) ) 43 | end 44 | 45 | def self.distributions( size, variant ) # 4,0 gives standard distrubtion 46 | 47 | distros = [[ 48 | # http://everything2.com/title/Boggle 49 | %w{ 50 | ASPFFK NUIHMQ OBJOAB LNHNRZ 51 | AHSPCO RYVDEL IOTMUC LREIXD 52 | TERWHV TSTIYD WNGEEH ERTTYL 53 | OWTOAT AEANEG EIUNES TOESSI 54 | }, 55 | 56 | # http://www.boardgamegeek.com/thread/300565/review-from-a-boggle-veteran-and-beware-differen 57 | %w{ 58 | AAEEGN ELRTTY AOOTTW ABBJOO 59 | EHRTVW CIMOTV DISTTY EIOSST 60 | DELRVY ACHOPS HIMNQU EEINSU 61 | EEGHNW AFFKPS HLNNRZ DEILRX 62 | }, 63 | 64 | %w{ 65 | AACIOT AHMORS EGKLUY ABILTY 66 | ACDEMP EGINTV GILRUW ELPSTU 67 | DENOSW ACELRS ABJMOQ EEFHIY 68 | EHINPS DKNOTU ADENVZ BIFORX 69 | } 70 | ],[ 71 | 72 | # http://boardgamegeek.com/thread/300883/letter-distribution 73 | %w{ 74 | aaafrs aaeeee aafirs adennn aeeeem 75 | aeegmu aegmnn afirsy bjkqxz ccenst 76 | ceiilt ceilpt ceipst ddhnot dhhlor 77 | dhlnor dhlnor eiiitt emottt ensssu 78 | fiprsy gorrvw iprrry nootuw ooottu 79 | }.map(&:upcase), 80 | 81 | %w{ 82 | AAAFRS AAEEEE AAFIRS ADENNN AEEEEM 83 | AEEGMU AEGMNN AFIRSY BJKQXZ CCNSTW 84 | CEIILT CEILPT CEIPST DHHNOT DHHLOR 85 | DHLNOR DDLNOR EIIITT EMOTTT ENSSSU 86 | FIPRSY GORRVW HIPRRY NOOTUW OOOTTU 87 | } 88 | ]] 89 | 90 | min_size = 4 91 | 92 | distros[size-min_size].map { |dist| 93 | dist.map { |die| 94 | die.split(//).map { |letter| 95 | # our distributions return Qu, not Q's 96 | letter == 'Q' ? 'Qu' : letter 97 | } 98 | } 99 | }[variant] 100 | end 101 | 102 | def available_styles 103 | if @size > 4 104 | return ["compact"] 105 | end 106 | letters.include?("Qu") ? ["wide"] : ["wide", "compact"] 107 | end 108 | 109 | def to_s(style = "wide") 110 | s = "" 111 | @size.times do |row| 112 | @size.times do |col| 113 | l = @board[row][col] 114 | if style == "wide" 115 | (l == "Qu") ? s << " #{l}" : s << " #{l} " 116 | else 117 | s << "#{l} " 118 | end 119 | end 120 | s << "\n" 121 | end 122 | s 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /botgle.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'rubygems' 4 | require 'chatterbot/dsl' 5 | 6 | require './manager' 7 | 8 | require './utils' 9 | 10 | # remove this to update the db 11 | no_update 12 | 13 | # remove this to get less output when running 14 | verbose 15 | 16 | Thread.abort_on_exception = true 17 | 18 | #tweets = client.user_timeline(count:200).collect(&:id) 19 | #client.destroy_status(tweets) 20 | #exit 21 | 22 | $mutex = Mutex.new 23 | @sleep_rate = 3 24 | @manager = Manager.new 25 | 26 | STDERR.puts "Loaded Game:" 27 | STDERR.puts @manager.inspect 28 | 29 | GAME_REMINDER_TIME = 60 * 60 * 2 30 | ADMIN_USERS = ["muffinista"] 31 | 32 | 33 | use_streaming true 34 | 35 | followed do |user| 36 | follow user 37 | end 38 | 39 | home_timeline do |tweet| 40 | $mutex.synchronize { 41 | #STDERR.puts tweet.text 42 | next if tweet.text !~ /^@botgle/i || ! @manager.active? 43 | 44 | STDERR.puts "PLAY #{Time.now}\t#{tweet.user.screen_name}\t#{tweet.text}" 45 | 46 | target = tweet.user.screen_name 47 | 48 | @manager.record_user(tweet.user.id, tweet.user.screen_name) 49 | 50 | words = [] 51 | 52 | tries = tweet.text.gsub(/@botgle/, "").gsub(/[\s,]+/m, ' ').strip.split(" ") 53 | g = @manager.game 54 | prior_count = g.plays.count 55 | 56 | g.play_words(tweet.user.id, tries) do |words, score| 57 | if ! words.empty? 58 | favorite tweet 59 | 60 | result = words.join(" ").upcase 61 | reply "#USER# plays #{result} #{flair}", tweet 62 | 63 | if prior_count <= 0 && @manager.game.plays.count > 0 64 | output = [ 65 | "The timer is started! #{DURATION / 60} minutes to play!", 66 | g.board.to_s(g.style).to_full_width, 67 | flair 68 | ].join("\n") 69 | 70 | tweet output 71 | end 72 | end 73 | end 74 | } 75 | end 76 | 77 | direct_messages do |tweet| 78 | STDERR.puts "well, here i am #{tweet.sender.screen_name}: #{tweet.text}" 79 | STDERR.puts tweet.inspect 80 | $mutex.synchronize { 81 | # 82 | # command interface for admin users only 83 | # 84 | if ADMIN_USERS.include? tweet.sender.screen_name 85 | if tweet.text =~ /TWEET BOARD/ 86 | tweet_state("active") 87 | end 88 | 89 | if tweet.text =~ /NEW GAME/ 90 | @manager.trigger_new_game 91 | direct_message "got it #{Time.now.to_i}", tweet.sender 92 | end 93 | 94 | if tweet.text =~ /LEADERBOARD/ 95 | s = @manager.season 96 | data = s.leaderboard 97 | @manager.pretty_leaderboard(data, "Season Point Totals:").each { |t| 98 | tweet t 99 | } 100 | 101 | data = s.game_winners 102 | @manager.pretty_leaderboard(data, "Season Victories").each { |t| 103 | tweet t 104 | } 105 | end 106 | 107 | if tweet.text =~ /NEW SEASON/ 108 | @manager.start_new_season 109 | tweet "A new season begins.... now! #{flair}#{flair}#{flair}" 110 | end 111 | end 112 | 113 | if tweet.text =~ /^NOTIFY/i 114 | @manager.set_user_notify(tweet.sender, true) 115 | direct_message "OK, I'll let you know when a game is coming up! #{flair}" 116 | elsif tweet.text =~ /^WARN/i 117 | @manager.set_user_notify(tweet.sender, true, 1) 118 | direct_message "OK, I'll let you know one minute before games start! #{flair}" 119 | elsif tweet.text =~ /^STOP/i 120 | @manager.set_user_notify(tweet.sender, false) 121 | direct_message "OK, I'll stop annoying you about Botgle games #{flair}" 122 | end 123 | } 124 | end 125 | 126 | 127 | def tweet_state(type) 128 | if type == "active" 129 | g = @manager.game 130 | 131 | base = ["THE BOARD:", 132 | "Boggle Summons You:", 133 | "TIME FOR BOGGLE:", 134 | "The mist clears. Time for Boggle:", 135 | "You see a Boggle board in the distance:", 136 | "You awaken from a dream of eldritch horrors to find a game before you:", 137 | "The only thing blocking you from total victory is this Boggle board:", 138 | "B-O-G-G-L-E", 139 | "Above you a skywriter dances the path of a Boggle board", 140 | "Your dreams are haunted by visions of Boggle", 141 | "I love you. Let's play:", 142 | "Would you like to play a game of Boggle?", 143 | "I hear you like to play Boggle" 144 | ].sample 145 | 146 | output = [ 147 | "#{base}\n\n", 148 | g.board.to_s(g.style).to_full_width, 149 | "#{flair} #{flair} #{flair}" 150 | ].join("\n") 151 | 152 | tweet output 153 | 154 | @game_state_tweet_at = Time.now.to_i 155 | elsif type == "lobby" 156 | g = @manager.last_game 157 | 158 | @manager.pretty_scores(g).each { |t| 159 | tweet t 160 | } 161 | 162 | if ! Time.now.in_this_month?(@manager.next_game_at) 163 | s = @manager.season 164 | data = s.leaderboard 165 | @manager.pretty_leaderboard(data, "Season Point Totals:").each { |t| 166 | tweet t 167 | } 168 | 169 | data = s.game_winners 170 | @manager.pretty_leaderboard(data, "Season Victories").each { |t| 171 | tweet t 172 | } 173 | 174 | @manager.start_new_season 175 | tweet "A new season begins.... now! #{flair}#{flair}#{flair}" 176 | end 177 | 178 | diff = @manager.next_game_at.to_i - Time.now.to_i 179 | tweet "Next game in #{(diff.to_f / 60 / 60).round.to_i} hours! #{flair}" 180 | end 181 | end 182 | 183 | def flair 184 | Manager::FLAIR.sample 185 | end 186 | 187 | 188 | 189 | def run_bot 190 | @game_state_tweet_at = Time.now.to_i 191 | 192 | STDERR.puts "run bot!" 193 | timer_thread = Thread.new { 194 | while(true) do 195 | begin 196 | $mutex.synchronize { 197 | 198 | # 199 | # output some debugging/tracking info 200 | # 201 | if @manager.state == "active" 202 | STDERR.puts @manager.game.inspect 203 | else 204 | STDERR.puts "#{@manager.state} #{Time.now} #{@manager.next_game_at}" 205 | end 206 | 207 | # NOTE this block is only called if the state of the game has changed 208 | @manager.tick { |game, state| 209 | STDERR.puts "Game state changed to #{@manager.state}" 210 | 211 | if state == "active" || state == "lobby" 212 | tweet_state @manager.state 213 | end 214 | } 215 | 216 | if @manager.state == "lobby" 217 | ten_minutes_before = @manager.next_game_at.to_i - (60*10) 218 | one_minute_before = @manager.next_game_at.to_i - 60 219 | 220 | if @manager.heads_up_issued == false && Time.now.to_i >= ten_minutes_before 221 | @manager.heads_up_issued = true 222 | tweet "Hey there! Boggle in 10 minutes! #{flair}" 223 | @manager.notifications.each { |n| 224 | begin 225 | msg = [ 226 | "Hey! There's a new game of botgle in 10 minutes!", 227 | "Botgle in 10 minutes!", 228 | "BEWARE: Botgle starts in 10 minutes!", 229 | "**WARNING** a game of botgle is just 10 minutes away!" 230 | ].sample 231 | STDERR.puts "NOTIFY #{n} #{msg} #{flair}" 232 | direct_message "#{msg} #{flair}", n 233 | rescue StandardError => e 234 | STDERR.puts "OOOOPS" 235 | STDERR.puts "NOTIFY #{e}" 236 | end 237 | } 238 | end 239 | 240 | if @manager.one_minute_warning_issued == false && Time.now.to_i >= one_minute_before 241 | @manager.one_minute_warning_issued = true 242 | @manager.one_minute_warnings.each { |n| 243 | begin 244 | msg = [ 245 | "EMERGENCY!!! Boggle in ONE MINUTE", 246 | "Hey! Boggle starts in a minute!", 247 | "BEWARE: Botgle starts in one minute!", 248 | "**WARNING** a game of botgle is just ONE minute away!" 249 | ].sample 250 | STDERR.puts "NOTIFY #{n} #{msg} #{flair}" 251 | direct_message "#{msg} #{flair} #{flair}", n 252 | rescue StandardError => e 253 | STDERR.puts e 254 | STDERR.puts "NOTIFY #{e}" 255 | end 256 | } 257 | end 258 | 259 | elsif @manager.state == "active" 260 | if @manager.game.issue_warning? 261 | @manager.game.warning_issued! 262 | output = [ 263 | "Warning! Just #{Game::WARNING_TIME / 60} minutes left", 264 | @manager.game.board.to_s(@manager.game.style).to_full_width, 265 | flair, 266 | "" 267 | ].join("\n") 268 | 269 | tweet output 270 | elsif @manager.game.plays.count == 0 && 271 | Time.now.to_i - @game_state_tweet_at > GAME_REMINDER_TIME 272 | tweet_state @manager.state 273 | end 274 | end 275 | } 276 | 277 | sleep @sleep_rate 278 | rescue StandardError => e 279 | STDERR.puts "timer thread exception #{e.inspect}" 280 | raise e 281 | end 282 | end 283 | STDERR.puts "EXITING TIMER" 284 | } 285 | 286 | streaming_thread = Thread.new { 287 | bot.stream! 288 | STDERR.puts "EXITING STREAMING" 289 | } 290 | 291 | check_thread = Thread.new { 292 | while true do 293 | sleep @sleep_rate + 5 294 | 295 | [timer_thread, streaming_thread].each { |t| 296 | if t.nil? || t.status == nil || t.status == false 297 | STDERR.puts "Thread #{t} died, let's jet" 298 | timer_thread && timer_thread.terminate 299 | streaming_thread && streaming_thread.terminate 300 | 301 | Thread.exit 302 | end 303 | } 304 | end 305 | STDERR.puts "EXITING CHECK" 306 | } 307 | 308 | timer_thread.run 309 | streaming_thread.run 310 | check_thread.join 311 | end 312 | 313 | 314 | while true do 315 | begin 316 | run_bot 317 | rescue Exception => e 318 | STDERR.puts e.inspect 319 | end 320 | STDERR.puts "oops, something went wrong, restarting in 20" 321 | sleep 20 322 | end 323 | 324 | -------------------------------------------------------------------------------- /game.rb: -------------------------------------------------------------------------------- 1 | require './board' 2 | require './trie' 3 | require './solver' 4 | require './play' 5 | require 'oj' 6 | 7 | require 'aws-sdk' 8 | 9 | DURATION = 8 * 60 10 | WARNING_TIME = 3 * 60 11 | MIN_WORDS_ON_BOARD = 65 12 | 13 | class Array 14 | # basically a case-insensitive version of include? 15 | def has_word?(w) 16 | any?{ |s| s.casecmp(w) == 0 } 17 | end 18 | end 19 | 20 | class Game 21 | attr_reader :board 22 | attr_reader :plays 23 | attr_reader :id 24 | attr_reader :style 25 | 26 | attr_accessor :warning 27 | 28 | 29 | def initialize(id) 30 | @id = id 31 | @board = nil 32 | 33 | if File.exist?(filename) 34 | load 35 | end 36 | 37 | if @board.nil? 38 | @board, @words = generate_decent_board 39 | 40 | @found_words = [] 41 | @time_started_at = Time.now 42 | @plays = [] 43 | @warning = false 44 | @style = @board.available_styles.sample 45 | end 46 | 47 | save 48 | end 49 | 50 | def generate_decent_board 51 | b = nil 52 | w = nil 53 | count = 0 54 | 55 | target = if rand > 0.7 56 | MIN_WORDS_ON_BOARD * 1.35 57 | else 58 | MIN_WORDS_ON_BOARD 59 | end 60 | 61 | #size = rand > 0.8 ? 5 : 4 62 | size = 4 63 | 64 | while count < target 65 | STDERR.puts "Generating new board" 66 | b = Board.new(size: size) 67 | 68 | trie = Marshal.load(File.read('./words.dict')) 69 | s = Solver.new(trie) 70 | s.solve(b) 71 | w = s.words 72 | 73 | count = w.count 74 | STDERR.puts "board has #{count} words" 75 | end 76 | 77 | return b, w 78 | end 79 | 80 | def filename 81 | "games/#{@id}.json" 82 | end 83 | 84 | def finish! 85 | begin 86 | to_s3 87 | rescue 88 | nil 89 | end 90 | end 91 | 92 | def warning_issued! 93 | @warning = true 94 | save 95 | end 96 | 97 | def issue_warning? 98 | @warning == false && time_remaining <= WARNING_TIME 99 | end 100 | 101 | def time_remaining 102 | # no countdown until someone makes a play 103 | if @plays.count == 0 104 | return 1000 105 | end 106 | 107 | elapsed = Time.now.to_i - @plays.first.played_at.to_i 108 | STDERR.puts "TIME REMAINING #{elapsed} #{DURATION - elapsed}" 109 | DURATION - elapsed 110 | end 111 | 112 | def play_words(target, tries) 113 | words = [] 114 | score = 0 115 | 116 | tries.each { |w| 117 | STDERR.puts "*** #{w}" 118 | p = Play.new(target, w) 119 | 120 | if try_play(p) 121 | @plays << p 122 | @found_words << p.word 123 | 124 | words << w 125 | score += p.score 126 | end 127 | } 128 | 129 | save 130 | 131 | yield(words, score) if block_given? 132 | 133 | [words, score] 134 | end 135 | 136 | def try_play(play) 137 | STDERR.puts "trying to play #{play.word}" 138 | test = play.word.upcase 139 | if @words.has_word?(test) && ! @found_words.has_word?(test) 140 | true 141 | else 142 | false 143 | end 144 | end 145 | 146 | def scores 147 | self.plays. 148 | group_by { |p| p.player }. 149 | collect { |k, v| [k, v.collect(&:score).inject(:+)]}. 150 | sort { |x| -x.last }.sort_by { |k, v| -v }.to_h 151 | end 152 | 153 | def winning_score 154 | scores[self.scores.keys.first] 155 | end 156 | 157 | def winners 158 | hi_score = winning_score 159 | puts "HIGH SCORE #{hi_score}" 160 | scores.select { |k, v| v >= hi_score }.keys 161 | end 162 | 163 | def load 164 | STDERR.puts "load #{filename}" 165 | file = File.read(filename) 166 | h = Oj.load(file) 167 | 168 | @words = h["words"] || [] 169 | @found_words = h["found_words"] || [] 170 | @board = h["board"] && Board.new(letters:h["board"]) 171 | @plays = h["plays"] || [] 172 | @time_started_at = h["time_started_at"] 173 | @warning = h["warning"] || false 174 | @style = h["style"] || @board.available_styles.sample 175 | end 176 | 177 | def to_h 178 | { 179 | "board" => @board.letters, 180 | "words" => @words, 181 | "found_words" => @found_words, 182 | "time_started_at" => @time_started_at, 183 | "plays" => @plays, 184 | "warning" => @warning, 185 | "style" => @style 186 | } 187 | end 188 | 189 | def save 190 | File.open(filename, "w") do |f| 191 | f.write(Oj.dump(to_h)) 192 | end 193 | end 194 | 195 | def to_s3 196 | s3 = Aws::S3::Resource.new(region:'us-east-1') 197 | bucket = s3.bucket('botgle') 198 | 199 | object = bucket.object(filename) 200 | object.put(body: Oj.dump(to_h), acl:'public-read') 201 | end 202 | 203 | class << self 204 | def create_dictionary(src, dest) 205 | trie = Trie.new 206 | File.open(src).each_line do |line| 207 | word = line.upcase.chomp 208 | 209 | # skip words that are too small for boggle 210 | if word.size > 2 211 | trie[word] = word 212 | end 213 | end 214 | 215 | dump = Marshal.dump(trie) 216 | dict_file = File.new(dest, "w") 217 | 218 | dict_file.write dump 219 | dict_file.close 220 | end 221 | end 222 | 223 | end 224 | -------------------------------------------------------------------------------- /games/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muffinista/botgle/2d4ed33c4ffe139005821c8ab8cd7b0a73fbeb0f/games/.gitkeep -------------------------------------------------------------------------------- /generate-dictionary.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'rubygems' 4 | require './game' 5 | 6 | Game.create_dictionary("words/words-full-2", "./words.dict") 7 | -------------------------------------------------------------------------------- /generate-words.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # this doesn't actually work but is relatively close to correct 4 | # grep -v "[A-Z]" /usr/share/dict/words | tr '[a-z]' '[A-Z]' > words/words-capitalized 5 | # cat words/words-capitalized words/sowpods.txt > words/all-words 6 | # cat words/all-words | sed -e 's/^ *//g;s/ *$//g' | sed `echo "s/\r//"` | sort | uniq > words/words-full-2 7 | 8 | 9 | # run with just sowpods 10 | cat words/sowpods.txt | sed -e 's/^ *//g;s/ *$//g' | sed "s/$(printf '\r')\$//" | sort | uniq > words/words-full-2 11 | 12 | -------------------------------------------------------------------------------- /manager.rb: -------------------------------------------------------------------------------- 1 | require 'oj' 2 | require './game' 3 | require './season' 4 | require './utils' 5 | 6 | require 'twitter-text' 7 | 8 | class Manager 9 | #GAME_HOURS = [3, 9, 15, 21] 10 | GAME_HOURS = [2, 8, 14, 20] 11 | 12 | FLAIR = [ 13 | #Twitter::Unicode::U1F385, # santa 14 | #Twitter::Unicode::U1F381, # gift 15 | #Twitter::Unicode::U2744, # snowflake 16 | #Twitter::Unicode::U1F384, # xmas tree 17 | #Twitter::Unicode::U2603, # snowman 18 | #Twitter::Unicode::U26C4 # snowman 2 19 | 20 | Twitter::Unicode::U1F3C6, 21 | Twitter::Unicode::U1F4AF, 22 | Twitter::Unicode::U1F386, 23 | Twitter::Unicode::U1F387, 24 | Twitter::Unicode::U1F638, 25 | Twitter::Unicode::U1F38A, 26 | Twitter::Unicode::U1F48E, 27 | Twitter::Unicode::U1F380, 28 | Twitter::Unicode::U1F525, 29 | Twitter::Unicode::U2728, 30 | Twitter::Unicode::U1F4A5, 31 | Twitter::Unicode::U1F31F, 32 | Twitter::Unicode::U1F4AB, 33 | Twitter::Unicode::U1F680, 34 | Twitter::Unicode::U2668, 35 | Twitter::Unicode::U1F40C, 36 | Twitter::Unicode::U1F409, 37 | Twitter::Unicode::U1F432, 38 | Twitter::Unicode::U2600, 39 | Twitter::Unicode::U1F308, 40 | Twitter::Unicode::U1F38A, 41 | Twitter::Unicode::U1F47E, 42 | Twitter::Unicode::U1F3B6, 43 | Twitter::Unicode::U1F3AF, 44 | Twitter::Unicode::U1F3C1 45 | ] 46 | 47 | attr_accessor :game 48 | attr_reader :state 49 | attr_reader :season 50 | attr_reader :users 51 | attr_reader :next_game_at 52 | attr_reader :notifications 53 | attr_reader :one_minute_warnings 54 | 55 | attr_accessor :heads_up_issued 56 | attr_accessor :one_minute_warning_issued 57 | 58 | def initialize 59 | @state = "lobby" 60 | @next_game_at = Time.now 61 | @game_id = nil 62 | @season_id = nil 63 | 64 | @users = {} 65 | @notifications = [] 66 | @one_minute_warnings = [] 67 | 68 | @mutex = Mutex.new 69 | 70 | @heads_up_issued = false 71 | @one_minute_warning_issued = false 72 | 73 | if File.exist?("manager.json") 74 | load 75 | end 76 | 77 | if File.exist?("users.json") 78 | load_users 79 | end 80 | end 81 | 82 | def last_game 83 | Game.new(@game_id) 84 | end 85 | 86 | def load_users(src="users.json") 87 | @mutex.synchronize { 88 | file = File.read(src) 89 | @users = Oj.load(file) 90 | } 91 | end 92 | 93 | def record_user(id, screen_name) 94 | @users[id] = screen_name 95 | 96 | @mutex.synchronize { 97 | File.open("users.json", "w") do |f| 98 | f.write(Oj.dump(@users)) 99 | end 100 | } 101 | 102 | @users 103 | end 104 | 105 | def set_user_notify(user, notify=true, _when=10) 106 | if notify == true 107 | if _when == 10 108 | @notifications << user.id unless @notifications.include?(user.id) 109 | else 110 | @one_minute_warnings << user.id unless @one_minute_warnings.include?(user.id) 111 | end 112 | else 113 | @notifications.delete(user.id) 114 | @one_minute_warnings.delete(user.id) 115 | end 116 | 117 | save 118 | end 119 | 120 | def finish_current_game 121 | puts "finishing the current game" 122 | @state = "lobby" 123 | @game.finish! 124 | @game = nil 125 | 126 | if @new_game_request == true 127 | @next_game_at = Time.now 128 | else 129 | @next_game_at = next_game_should_be_at 130 | @heads_up_issued = false 131 | @one_minute_warning_issued = false 132 | 133 | 134 | 135 | 136 | end 137 | 138 | begin 139 | to_s3 140 | rescue 141 | nil 142 | end 143 | 144 | save 145 | end 146 | 147 | def next_game_should_be_at 148 | t = Time.now.beginning_of_next_hour 149 | while !GAME_HOURS.include?(t.hour) 150 | t = t + (3600) 151 | end 152 | 153 | t 154 | end 155 | 156 | def trigger_new_game 157 | @new_game_request = true 158 | end 159 | 160 | def start_new_game 161 | @new_game_request = false 162 | if active? 163 | finish_current_game 164 | end 165 | 166 | @state = "active" 167 | @game_id = @game_id.to_i + 1 168 | @game = Game.new(@game_id) 169 | 170 | if @season.nil? 171 | start_new_season 172 | end 173 | 174 | @season.add_game(@game) 175 | 176 | save 177 | end 178 | 179 | def start_new_season 180 | @season_id = @season_id.to_i + 1 181 | @season = Season.new(@season_id) 182 | 183 | save 184 | end 185 | 186 | def active? 187 | @state == "active" 188 | end 189 | 190 | def need_to_finish? 191 | @new_game_request || (active? && @game.time_remaining <= 0) 192 | end 193 | 194 | def need_to_start? 195 | #STDERR.puts "#{!active?} && #{Time.now.to_i} <= #{@next_game_at.to_i} #{Time.now.to_i <= @next_game_at.to_i}" 196 | @new_game_request || (!active? && Time.now.to_i >= @next_game_at.to_i) 197 | end 198 | 199 | 200 | # take the scores for this game and turn them into a nicely 201 | # formatted text, split across a couple tweets 202 | # note: could run the same code for a season 203 | def pretty_scores(game) 204 | prefix = "GAME OVER! SCORES:" 205 | guts = game.scores.collect { |id, points| 206 | name = @users[id] || id 207 | word = points.to_i > 1 ? "points" : "point" 208 | "@#{name}: #{points} #{FLAIR.sample}" 209 | }.join("\n") 210 | 211 | "#{prefix}\n#{guts}".pretty_split 212 | end 213 | 214 | def pretty_leaderboard(data, prefix="GAME OVER! SCORES:", limit=5, type="point") 215 | guts = data.collect { |id, points| 216 | name = @users[id] || id 217 | word = points.to_i > 1 ? "#{type}s" : type 218 | "@#{name}: #{points} #{FLAIR.sample}" 219 | }.first(limit).join("\n") 220 | 221 | "#{prefix}\n#{guts}".pretty_split 222 | end 223 | 224 | 225 | def tick 226 | do_yield = false 227 | if active? 228 | if need_to_finish? 229 | do_yield = true 230 | finish_current_game 231 | end 232 | end 233 | 234 | if need_to_start? 235 | do_yield = true 236 | start_new_game 237 | end 238 | 239 | if do_yield && block_given? 240 | yield @game, @state 241 | end 242 | 243 | @state 244 | end 245 | 246 | 247 | def load(filename="manager.json") 248 | file = File.read(filename) 249 | h = Oj.load(file) 250 | 251 | @state = h["state"] 252 | @next_game_at = h["next_game_at"] 253 | @game_id = h["game_id"] 254 | @season_id = h["season_id"] 255 | @notifications = h["notifications"] || [] 256 | @one_minute_warnings = h["one_minute_warnings"] || [] 257 | 258 | 259 | if @game_id.to_i > 0 && @state == "active" 260 | @game = Game.new(@game_id) 261 | end 262 | if @season_id.to_i > 0 263 | @season = Season.new(@season_id) 264 | end 265 | end 266 | 267 | def to_s3 268 | @season.to_s3 269 | 270 | s3 = Aws::S3::Resource.new(region:'us-east-1') 271 | bucket = s3.bucket('botgle') 272 | 273 | object = bucket.object("users.json") 274 | object.put(body: Oj.dump(@users), acl:'public-read') 275 | end 276 | 277 | 278 | def save(filename="manager.json") 279 | hash = { 280 | "state" => @state, 281 | "next_game_at" => @next_game_at, 282 | "game_id" => @game_id, 283 | "season_id" => @season_id, 284 | "notifications" => @notifications, 285 | "one_minute_warnings" => @one_minute_warnings 286 | } 287 | 288 | File.open(filename, "w") do |f| 289 | f.write(Oj.dump(hash)) 290 | end 291 | end 292 | end 293 | -------------------------------------------------------------------------------- /play.rb: -------------------------------------------------------------------------------- 1 | class Play 2 | attr_accessor :player 3 | attr_accessor :word 4 | attr_reader :played_at 5 | 6 | def initialize(player, word) 7 | @player = player 8 | @word = word 9 | @played_at = Time.now 10 | end 11 | 12 | def score 13 | case word.size 14 | when 0,1,2 then 0 15 | when 3,4 then 1 16 | when 5 then 2 17 | when 6 then 3 18 | when 7 then 5 19 | else 11 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /season.rb: -------------------------------------------------------------------------------- 1 | require 'oj' 2 | 3 | class Season 4 | def initialize(id) 5 | @id = id 6 | if File.exist?(filename) 7 | load 8 | end 9 | 10 | @scores = {} if @scores.nil? 11 | @games = [] if @games.nil? 12 | @time_started_at ||= Time.now 13 | 14 | save 15 | end 16 | 17 | def filename 18 | "seasons/#{@id}.json" 19 | end 20 | 21 | def finish! 22 | end 23 | 24 | def leaderboard 25 | result = {} 26 | @games.each { |id| 27 | g = Game.new(id) 28 | g.scores.each { |p, v| 29 | result[p] ||= 0 30 | result[p] += v 31 | } 32 | } 33 | result.sort_by { |k, v| -v }.to_h 34 | end 35 | 36 | def game_winners 37 | result = {} 38 | @games.each { |id| 39 | g = Game.new(id) 40 | value = 1.0 / g.winners.count 41 | g.winners.each { |p| 42 | result[p] ||= 0 43 | result[p] += value 44 | result[p] = result[p].round(2) 45 | } 46 | } 47 | result.sort_by { |k, v| -v }.to_h 48 | end 49 | 50 | def add_game(g) 51 | @games << g.id 52 | save 53 | end 54 | 55 | def load 56 | STDERR.puts "load #{filename}" 57 | file = File.read(filename) 58 | h = Oj.load(file) 59 | 60 | @scores = h["scores"] || {} 61 | @games = h["games"] || [] 62 | @time_started_at = h["time_started_at"] 63 | end 64 | 65 | def to_h 66 | { 67 | "scores" => @scores, 68 | "games" => @games, 69 | "time_started_at" => @time_started_at 70 | } 71 | end 72 | 73 | def to_s3 74 | s3 = Aws::S3::Resource.new(region:'us-east-1') 75 | bucket = s3.bucket('botgle') 76 | 77 | object = bucket.object(filename) 78 | object.put(body: Oj.dump(to_h), acl:'public-read') 79 | end 80 | 81 | def save 82 | File.open(filename, "w") do |f| 83 | f.write(Oj.dump(to_h)) 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /seasons/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muffinista/botgle/2d4ed33c4ffe139005821c8ab8cd7b0a73fbeb0f/seasons/.gitkeep -------------------------------------------------------------------------------- /solver.rb: -------------------------------------------------------------------------------- 1 | require './trie' 2 | require './game' 3 | 4 | class Solver 5 | attr_reader :found_words 6 | 7 | def initialize(trie) 8 | @found_words = Set.new 9 | @trie = trie 10 | end 11 | 12 | def in_trie?(prefix) 13 | if (d = @trie.match(prefix.upcase)) # not nil = okay 14 | if d.class == String 15 | @found_words << prefix 16 | end 17 | true 18 | end 19 | end 20 | 21 | def words 22 | @found_words.to_a.sort 23 | end 24 | 25 | def start(board) 26 | solve board 27 | @found_words 28 | end 29 | 30 | def solve(board) 31 | board.size.times do |row| 32 | board.size.times do |col| 33 | solve_frame(make_frame("", board, row, col)) 34 | end 35 | end 36 | end 37 | 38 | private 39 | 40 | def make_frame(prefix, board, row, col) 41 | frame = [] 42 | frame << (prefix + board[row,col]) 43 | new_board = board.deepcopy 44 | new_board[row,col] = nil 45 | frame << new_board 46 | frame << row << col 47 | frame 48 | end 49 | 50 | def solve_frame(frame) 51 | next_frames(frame).each do |f| 52 | prefix, b, r, c = f 53 | 54 | if in_trie? prefix 55 | # continue 56 | solve_frame(f) 57 | end 58 | # otherwise we're at a dead end!, ignore the frame 59 | # and continue to the other ones 60 | end 61 | end 62 | 63 | def next_frames(frame) 64 | # unpack 65 | pre, board, row, col = frame 66 | 67 | frames = [] 68 | # row before 69 | frames << (board[row-1, col-1] && make_frame(pre, board, row-1, col-1)) 70 | frames << (board[row-1, col ] && make_frame(pre, board, row-1, col )) 71 | frames << (board[row-1, col+1] && make_frame(pre, board, row-1, col+1)) 72 | 73 | # same row 74 | frames << (board[row , col-1] && make_frame(pre, board, row , col-1)) 75 | # frames << (board[row , col ] && make_frame(pre, board, row, col )) 76 | # is guaranteed to be nil, since at row,col we are empty 77 | frames << (board[row , col+1] && make_frame(pre, board, row , col+1)) 78 | 79 | # row after 80 | frames << (board[row+1, col-1] && make_frame(pre, board, row+1, col-1)) 81 | frames << (board[row+1, col ] && make_frame(pre, board, row+1, col )) 82 | frames << (board[row+1, col+1] && make_frame(pre, board, row+1, col+1)) 83 | 84 | frames.compact 85 | end 86 | 87 | end 88 | -------------------------------------------------------------------------------- /stats.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'json' 4 | 5 | total_games = Dir.glob("games/*.json").count 6 | 7 | words = Dir.glob("games/*.json").collect { |f| JSON.parse(File.read(f))["found_words"] }.flatten 8 | total_words = words.count 9 | 10 | plays = Dir.glob("games/*.json").collect { |f| JSON.parse(File.read(f))["plays"] }.flatten; nil 11 | total_players = plays.collect { |p| p["player"] }.uniq.count 12 | 13 | 14 | puts "TOTAL GAMES:\t#{total_games}" 15 | puts "TOTAL WORDS:\t#{total_words}" 16 | puts "TOTAL PLAYERS:\t#{total_players}" 17 | -------------------------------------------------------------------------------- /trie.rb: -------------------------------------------------------------------------------- 1 | require 'algorithms' 2 | 3 | class Trie < Containers::Trie 4 | 5 | # returns either nil if there is nothing along that path, true 6 | # if that path exists in the tree and the word itself if it is an endpoint 7 | 8 | def match(string) 9 | string = string.to_s 10 | return nil if string.empty? 11 | match_recursive(@root, string, 0) 12 | end 13 | 14 | def match_recursive(node, string, index) 15 | return nil if node.nil? 16 | 17 | char = string[index] 18 | 19 | if (char < node.char) 20 | match_recursive(node.left, string, index) 21 | elsif (char > node.char) 22 | match_recursive(node.right, string, index) 23 | else 24 | return nil if node.nil? 25 | if index == (string.length - 1) 26 | if node.last? 27 | return node.value 28 | else 29 | return true 30 | end 31 | end 32 | match_recursive(node.mid, string, index+1) 33 | end 34 | end 35 | 36 | end 37 | -------------------------------------------------------------------------------- /utils.rb: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | class String 3 | def to_full_width 4 | offset = 65248 5 | self.each_byte.collect { |x| 6 | if x == 10 7 | "\n" 8 | else 9 | [x == 32 ? 12288 : (x + offset)].pack("U").freeze 10 | end 11 | }.join("") 12 | end 13 | 14 | def pretty_split(len=140) 15 | output = [] 16 | line = "" 17 | self.split(/\n/).each { |x| 18 | if ( (line + "\n" + x).size > 140 ) 19 | output << line.dup 20 | line = "" 21 | end 22 | line << x << "\n" 23 | } 24 | output << line.dup 25 | output 26 | end 27 | end 28 | 29 | class Time 30 | def beginning_of_next_hour 31 | now = self 32 | now = now - (now.min) * 60 33 | now = now - (now.sec) 34 | now + 3600 35 | end 36 | 37 | def in_this_month?(t) 38 | self.month === t.month 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /words.dict: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muffinista/botgle/2d4ed33c4ffe139005821c8ab8cd7b0a73fbeb0f/words.dict --------------------------------------------------------------------------------