4 # :title: A-Z Game Plugin for rbot
6 # Author:: Giuseppe "Oblomov" Bilotta <giuseppe.bilotta@gmail.com>
7 # Author:: Yaohan Chen <yaohan.chen@gmail.com>: Japanese support
9 # Copyright:: (C) 2006 Giuseppe Bilotta
10 # Copyright:: (C) 2007 GIuseppe Bilotta, Yaohan Chen
14 # A-Z Game: guess the word by reducing the interval of allowed ones
16 # TODO allow manual addition of words
20 attr_reader :range, :word
21 attr_reader :lang, :rules, :listener
22 attr_accessor :tries, :total_tries, :total_failed, :failed, :winner
23 def initialize(plugin, lang, rules, word)
28 @range = [@rules[:first].dup, @rules[:last].dup]
29 @listener = @rules[:listener]
31 @total_failed = 0 # not used, reported, updated
33 @failed = Hash.new(0) # not used, not reported, updated
36 return "%s -- %s" % self
39 @check_method = "is_#{@rules[:addlang]}?"
40 # trick: if addlang was not in rules, this will be is_? which is
41 # not a method of the plugin
42 if @check_method and not @plugin.respond_to? @check_method
45 @check = Proc.new do |w|
46 wl = @rules[:list].include?(w)
47 if !wl and @check_method
48 if wl = @plugin.send(@check_method, w)
49 debug "adding #{w} to #{@rules[:addfile]}"
51 File.open(@rules[:addfile], "a") do |f|
55 error "failed to add #{w} to #{@rules[:addfile]}"
63 @check_method = "is_#{@lang}?"
64 @check = Proc.new { |w| @plugin.send(@check_method, w) }
70 debug "checking #{w} for #{@word} in #{@range}"
71 # Since we're called threaded, bail out early if a winner
72 # was assigned already
73 return [:ignore, nil] if @winner
74 return [:bingo, nil] if w == @word
75 return [:out, @range] if w < @range.first or w > @range.last
76 return [:ignore, @range] if w == @range.first or w == @range.last
77 # This is potentially slow (for languages that check online)
78 return [:noexist, @range] unless @check.call(w)
80 # Check again if there was a winner in the mean time,
81 # and bail out if there was
82 return [:ignore, nil] if @winner
83 if w < @word and w > @range.first
84 @range.first.replace(w)
86 elsif w > @word and w < @range.last
87 @range.last.replace(w)
93 # TODO scoring: base score is t = ceil(100*exp(-((n-1)^2)/(50^2)))+p for n attempts
94 # done by p players; players that didn't win but contributed
95 # with a attempts will get t*a/n points
101 p = @tries.keys.length
102 t = (100*exp(-((n-1)**2)/(50.0**2))).ceil + p
103 debug "Total score: #{t}"
106 ret[k] = [t*a/n, n_("%{count} try", "%{count} tries", a) % {:count => a}]
109 debug "replacing winner score of %d with %d" % [ret[@winner].first, t]
110 tries = ret[@winner].last
111 ret[@winner] = [t, _("winner, %{tries}") % {:tries => tries}]
113 return ret.sort_by { |h| h.last.first }.reverse
118 class AzGamePlugin < Plugin
122 # if @registry.has_key?(:games)
123 # @games = @registry[:games]
127 if @registry.has_key?(:wordcache) and @registry[:wordcache]
128 @wordcache = @registry[:wordcache]
130 @wordcache = Hash.new
132 debug "A-Z wordcache: #{@wordcache.pretty_inspect}"
136 :good => /s\.f\.|s\.m\.|agg\.|v\.tr\.|v\.(pronom\.)?intr\./, # avv\.|pron\.|cong\.
139 :last => 'zuzzurellone',
140 :url => "http://www.demauroparavia.it/%s",
141 :wapurl => "http://wap.demauroparavia.it/index.php?lemma=%s",
142 :listener => /^[a-z]+$/
145 :good => /(?:singular )?noun|verb|adj/,
148 :url => "http://www.chambersharrap.co.uk/chambers/features/chref/chref.py/main?query=%s&title=21st",
149 :listener => /^[a-z]+$/
153 @wordlist_base = "#{@bot.botclass}/azgame/wordlist-"
154 @autoadd_base = "#{@bot.botclass}/azgame/autoadd-"
157 def initialize_wordlist(params)
159 addlang = params[:addlang]
160 wordlist = @wordlist_base + lang.to_s
161 autoadd = @autoadd_base + addlang.to_s
162 if File.exist?(wordlist)
163 # wordlists are assumed to be UTF-8, but we need to strip the BOM, if present
164 words = File.readlines(wordlist).map {|line| line.sub("\xef\xbb\xbf",'').strip}
165 if addlang and File.exist?(autoadd)
166 word += File.readlines(autoadd).map {|line| line.sub("\xef\xbb\xbf",'').strip}
170 if(words.length >= 4) # something to guess
180 debug "#{lang} wordlist loaded, #{rules[:list].length} lines; first word: #{rules[:first]}, last word: #{rules[:last]}"
188 # @registry[:games] = @games
189 @registry[:wordcache] = @wordcache
193 return if m.channel.nil? or m.address?
194 k = m.channel.downcase.to_s # to_sym?
195 return unless @games.key?(k)
197 word = m.plugin.downcase
198 return unless word =~ @games[k].listener
199 word_check(m, k, word)
202 def word_check(m, k, word)
203 # Not really safe ... what happens
205 isit = @games[k].check(word)
208 m.reply _("%{bold}BINGO!%{bold} the word was %{underline}%{word}%{underline}. Congrats, %{bold}%{player}%{bold}!") % {:bold => Bold, :underline => Underline, :word => word, :player => m.sourcenick}
209 @games[k].total_tries += 1
210 @games[k].tries[m.source] += 1
211 @games[k].winner = m.source
212 ar = @games[k].score.inject([]) { |res, kv|
213 res.push("%s: %d (%s)" % kv.flatten)
215 m.reply _("The game was won after %{tries} tries. Scores for this game: %{scores}") % {:tries => @games[k].total_tries, :scores => ar.join('; ')}
218 m.reply _("%{word} is not in the range %{bold}%{range}%{bold}") % {:word => word, :bold => Bold, :range => isit.last} if m.address?
220 # bail out early if the game was won in the mean time
221 return if !@games[k] or @games[k].winner
222 m.reply _("%{word} doesn't exist or is not acceptable for the game") % {:word => word}
223 @games[k].total_failed += 1
224 @games[k].failed[m.source] += 1
226 # bail out early if the game was won in the mean time
227 return if !@games[k] or @games[k].winner
228 m.reply _("close, but no cigar. New range: %{bold}%{range}%{bold}") % {:bold => Bold, :range => isit.last}
229 @games[k].total_tries += 1
230 @games[k].tries[m.source] += 1
232 m.reply _("%{word} is already one of the range extrema: %{range}") % {:word => word, :range => isit.last} if m.address?
234 m.reply _("hm, something went wrong while verifying %{word}")
239 def manual_word_check(m, params)
240 k = m.channel.downcase.to_s
241 word = params[:word].downcase
242 if not @games.key?(k)
243 m.reply _("no A-Z game running here, can't check if %{word} is valid, can I?")
247 m.reply _("I only accept single words composed by letters only, sorry")
250 word_check(m, k, word)
253 def stop_game(m, params)
254 return if m.channel.nil? # Shouldn't happen, but you never know
255 k = m.channel.downcase.to_s # to_sym?
257 m.reply _("the word in %{bold}%{range}%{bold} was: %{bold}%{word}%{bold}") % {:bold => Bold, :range => @games[k].range, :word => @games[k].word}
258 ar = @games[k].score.inject([]) { |res, kv|
259 res.push("%s: %d (%s)" % kv.flatten)
261 m.reply _("The game was cancelled after %{tries} tries. Scores for this game would have been: %{scores}") % {:tries => @games[k].total_tries, :scores => ar.join('; ')}
264 m.reply _("no A-Z game running in this channel ...")
268 def start_game(m, params)
269 return if m.channel.nil? # Shouldn't happen, but you never know
270 k = m.channel.downcase.to_s # to_sym?
271 unless @games.key?(k)
272 lang = (params[:lang] || @bot.config['core.language']).to_sym
273 method = 'random_pick_'+lang.to_s
274 m.reply _("let me think ...")
275 if @rules.has_key?(lang) and self.respond_to?(method)
276 word = self.send(method)
278 m.reply _("couldn't think of anything ...")
282 @games[k] = AzGame.new(self, lang, @rules[lang], word)
283 elsif !@rules.has_key?(lang) and rules = initialize_wordlist(params)
284 word = random_pick_wordlist(rules)
286 m.reply _("couldn't think of anything ...")
290 @games[k] = AzGame.new(self, lang, rules, word)
292 m.reply _("I can't play A-Z in %{lang}, sorry") % {:lang => lang}
296 tr = @games[k].total_tries
297 # this message building code is rewritten to make translation easier
301 f_tr = @games[k].total_failed
303 tr_msg = _(" (after %{total_tries} and %{invalid_tries})") %
304 { :total_tries => n_("%{count} try", "%{count} tries", tr) %
306 :invalid_tries => n_("%{count} invalid try", "%{count} invalid tries", tr) %
309 tr_msg = _(" (after %{total_tries})") %
310 { :total_tries => n_("%{count} try", "%{count} tries", tr) %
315 m.reply _("A-Z: %{bold}%{range}%{bold}") % {:bold => Bold, :range => @games[k].range} + tr_msg
319 def wordlist(m, params)
320 pars = params[:params]
321 lang = (params[:lang] || @bot.config['core.language']).to_sym
322 wc = @wordcache[lang] || Hash.new rescue Hash.new
323 cmd = params[:cmd].to_sym rescue :count
326 m.reply n_("I have %{count} %{lang} word in my cache", "I have %{count} %{lang} words in my cache", wc.size) % {:count => wc.size, :lang => lang}
329 m.reply _("provide a regexp to match")
334 matches = wc.keys.map { |k|
341 m.reply _("no %{lang} word I know match %{pattern}") % {:lang => lang, :pattern => pars[0]}
342 elsif matches.size > 25
343 m.reply _("more than 25 %{lang} words I know match %{pattern}, try a stricter matching") % {:lang => lang, :pattern => pars[0]}
345 m.reply "#{matches.join(', ')}"
349 m.reply _("provide a word")
352 word = pars[0].downcase.to_sym
354 m.reply _("I don't know any %{lang} word %{word}") % {:lang => lang, :word => word}
357 if wc[word].key?(:when)
358 tr = _("%{word} learned from %{user} on %{date}") % {:word => word, :user => wc[word][:who], :date => wc[word][:when]}
360 tr = _("%{word} learned from %{user}") % {:word => word, :user => wc[word][:who]}
365 m.reply _("provide a word")
368 word = pars[0].downcase.to_sym
370 m.reply _("I don't know any %{lang} word %{word}") % {:lang => lang, :word => word}
377 m.reply _("provide a word")
380 word = pars[0].downcase.to_sym
382 m.reply _("I already know the %{lang} word %{word}")
385 wc[word] = { :who => m.sourcenick, :when => Time.now }
391 # return integer between min and max, inclusive
392 def rand_between(min, max)
393 rand(max - min + 1) + min
396 def random_pick_wordlist(rules, min=nil, max=nil)
397 min = rules[:first] if min.nil_or_empty?
398 max = rules[:last] if max.nil_or_empty?
399 debug "Randomly picking word between #{min} and #{max}"
400 min_index = rules[:list].index(min)
401 max_index = rules[:list].index(max)
402 debug "Index between #{min_index} and #{max_index}"
403 index = rand_between(min_index + 1, max_index - 1)
404 debug "Index generated: #{index}"
405 word = rules[:list][index]
406 debug "Randomly picked #{word}"
410 def is_italian?(word)
411 unless @wordcache.key?(:italian)
412 @wordcache[:italian] = Hash.new
414 wc = @wordcache[:italian]
415 return true if wc.key?(word.to_sym)
416 rules = @rules[:italian]
417 p = @bot.httputil.get(rules[:wapurl] % word, :open_timeout => 60, :read_timeout => 60)
419 error "could not connect!"
423 p.scan(/<anchor>#{word} - (.*?)<go href="lemma.php\?ID=([^"]*?)"/) { |qual, url|
424 debug "new word #{word} of type #{qual}"
425 if qual =~ rules[:good] and qual !~ rules[:bad]
426 wc[word.to_sym] = {:who => :dict}
434 def random_pick_italian(min=nil,max=nil)
435 # Try to pick a random word between min and max
440 m.reply "#{min} > #{max}"
443 rules = @rules[:italian]
444 min = rules[:first] if min.empty?
445 max = rules[:last] if max.empty?
446 debug "looking for word between #{min.inspect} and #{max.inspect}"
447 return word if min.empty? or max.empty?
449 while (word <= min or word >= max or word !~ /^[a-z]+$/)
450 debug "looking for word between #{min} and #{max} (prev: #{word.inspect})"
451 # TODO for the time being, skip words with extended characters
452 unless @wordcache.key?(:italian)
453 @wordcache[:italian] = Hash.new
455 wc = @wordcache[:italian]
458 cache_or_url = rand(2)
460 debug "getting word from wordcache"
461 word = wc.keys[rand(wc.size)].to_s
466 # TODO when doing ranges, adapt this choice
467 l = ('a'..'z').to_a[rand(26)]
468 debug "getting random word from dictionary, starting with letter #{l}"
469 first = rules[:url] % "lettera_#{l}_0_50"
470 p = @bot.httputil.get(first)
471 max_page = p.match(/ \/ (\d+)<\/label>/)[1].to_i
472 pp = rand(max_page)+1
473 debug "getting random word from dictionary, starting with letter #{l}, page #{pp}"
474 p = @bot.httputil.get(first+"&pagina=#{pp}") if pp > 1
478 # We look for a lemma composed by a single word and of length at least two
479 p.scan(/<li><a href="([^"]+?)" title="consulta il lemma ([^ "][^ "]+?)">.*? (.+?)<\/li>/) { |url, prelemma, tipo|
480 lemma = prelemma.downcase.to_sym
481 debug "checking lemma #{lemma} (#{prelemma}) of type #{tipo} from url #{url}"
482 next if wc.key?(lemma)
486 debug "refusing, #{bad}"
491 wc[lemma] = {:who => :dict}
493 debug "refusing, not #{good}"
496 word = lemmi[rand(lemmi.length)].to_s
499 error "error #{e.inspect} while looking up a word"
500 error e.backtrace.join("\n")
505 def is_english?(word)
506 unless @wordcache.key?(:english)
507 @wordcache[:english] = Hash.new
509 wc = @wordcache[:english]
510 return true if wc.key?(word.to_sym)
511 rules = @rules[:english]
512 p = @bot.httputil.get(rules[:url] % CGI.escape(word))
514 error "could not connect!"
518 if p =~ /<span class="(?:hwd|srch)">#{word}<\/span>([^\n]+?)<span class="psa">#{rules[:good]}<\/span>/i
519 debug "new word #{word}"
520 wc[word.to_sym] = {:who => :dict}
526 def random_pick_english(min=nil,max=nil)
527 # Try to pick a random word between min and max
532 m.reply "#{min} > #{max}"
535 rules = @rules[:english]
536 min = rules[:first] if min.empty?
537 max = rules[:last] if max.empty?
538 debug "looking for word between #{min.inspect} and #{max.inspect}"
539 return word if min.empty? or max.empty?
541 while (word <= min or word >= max or word !~ /^[a-z]+$/)
542 debug "looking for word between #{min} and #{max} (prev: #{word.inspect})"
543 # TODO for the time being, skip words with extended characters
544 unless @wordcache.key?(:english)
545 @wordcache[:english] = Hash.new
547 wc = @wordcache[:english]
550 cache_or_url = rand(2)
552 debug "getting word from wordcache"
553 word = wc.keys[rand(wc.size)].to_s
558 # TODO when doing ranges, adapt this choice
559 l = ('a'..'z').to_a[rand(26)]
560 ll = ('a'..'z').to_a[rand(26)]
561 random = [l,ll].join('*') + '*'
562 debug "getting random word from dictionary, matching #{random}"
563 p = @bot.httputil.get(rules[:url] % CGI.escape(random))
567 # We look for a lemma composed by a single word and of length at least two
568 p.scan(/<span class="(?:hwd|srch)">(.*?)<\/span>([^\n]+?)<span class="psa">#{rules[:good]}<\/span>/i) { |prelemma, discard|
569 lemma = prelemma.downcase
570 debug "checking lemma #{lemma} (#{prelemma}) and discarding #{discard}"
571 next if wc.key?(lemma.to_sym)
572 if lemma =~ /^[a-z]+$/
575 wc[lemma.to_sym] = {:who => :dict}
577 debug "funky characters, not good"
581 word = lemmi[rand(lemmi.length)]
584 error "error #{e.inspect} while looking up a word"
585 error e.backtrace.join("\n")
590 def help(plugin, topic="")
593 return _("az [lang] word [count|list|add|delete] => manage the az wordlist for language lang (defaults to current bot language)")
595 return _("az cancel => abort current game")
597 return _('az check <word> => checks <word> against current game')
599 return _("try to guess the word the bot is thinking of; if you guess wrong, the bot will use the new word to restrict the range of allowed words: eventually, the range will be so small around the correct word that you can't miss it")
601 return _("az => start a game if none is running, show the current word range otherwise; you can say 'az <language>' if you want to play in a language different from the current bot default")
603 offset = @wordlist_base.length
605 wls = Dir.glob(@wordlist_base + "*").map { |f| f[offset,f.length].intern rescue nil }.compact - langs
607 _("az topics: play, rules, cancel, manage, check"),
608 _("available languages: %{langs}") % { :langs => langs.join(", ") },
609 wls.empty? ? nil : _("available wordlists: %{wls}") % { :wls => wls.join(", ") },
616 plugin = AzGamePlugin.new
617 plugin.map 'az [:lang] word :cmd *params', :action=>'wordlist', :defaults => { :lang => nil, :cmd => 'count', :params => [] }, :auth_path => '!az::edit!'
618 plugin.map 'az cancel', :action=>'stop_game', :private => false
619 plugin.map 'az check :word', :action => 'manual_word_check', :private => false
620 plugin.map 'az [play] [:lang] [autoadd :addlang]', :action=>'start_game', :private => false, :defaults => { :lang => nil, :addlang => nil }