]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - data/rbot/plugins/games/azgame.rb
geoip plugin: had to change host name of the new lookup server
[user/henk/code/ruby/rbot.git] / data / rbot / plugins / games / azgame.rb
1 #-- vim:sw=2:et
2 #++
3 #
4 # :title: A-Z Game Plugin for rbot
5 #
6 # Author:: Giuseppe "Oblomov" Bilotta <giuseppe.bilotta@gmail.com>
7 # Author:: Yaohan Chen <yaohan.chen@gmail.com>: Japanese support
8 #
9 # Copyright:: (C) 2006 Giuseppe Bilotta
10 # Copyright:: (C) 2007 GIuseppe Bilotta, Yaohan Chen
11 #
12 # License:: GPL v2
13 #
14 # A-Z Game: guess the word by reducing the interval of allowed ones
15 #
16 # TODO allow manual addition of words
17
18 class AzGame
19
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)
24     @plugin = plugin
25     @lang = lang.to_sym
26     @word = word.downcase
27     @rules = rules
28     @range = [@rules[:first].dup, @rules[:last].dup]
29     @listener = @rules[:listener]
30     @total_tries = 0
31     @total_failed = 0 # not used, reported, updated
32     @tries = Hash.new(0)
33     @failed = Hash.new(0) # not used, not reported, updated
34     @winner = nil
35     def @range.to_s
36       return "%s -- %s" % self
37     end
38   end
39
40   def check(word)
41     w = word.downcase
42     debug "checking #{w} for #{@word} in #{@range}"
43     # Since we're called threaded, bail out early if a winner
44     # was assigned already
45     return [:ignore, nil] if @winner
46     return [:bingo, nil] if w == @word
47     return [:out, @range] if w < @range.first or w > @range.last
48     return [:ignore, @range] if w == @range.first or w == @range.last
49     # This is potentially slow (for languages that check online)
50     return [:noexist, @range] unless @plugin.send("is_#{@lang}?", w)
51     debug "we like it"
52     # Check again if there was a winner in the mean time,
53     # and bail out if there was
54     return [:ignore, nil] if @winner
55     if w < @word and w > @range.first
56       @range.first.replace(w)
57       return [:in, @range]
58     elsif w > @word and w < @range.last
59       @range.last.replace(w)
60       return [:in, @range]
61     end
62     return [:out, @range]
63   end
64
65 # TODO scoring: base score is t = ceil(100*exp(-((n-1)^2)/(50^2)))+p for n attempts
66 #               done by p players; players that didn't win but contributed
67 #               with a attempts will get t*a/n points
68
69   include Math
70
71   def score
72     n = @total_tries
73     p = @tries.keys.length
74     t = (100*exp(-((n-1)**2)/(50.0**2))).ceil + p
75     debug "Total score: #{t}"
76     ret = Hash.new
77     @tries.each { |k, a|
78       ret[k] = [t*a/n, n_("%{count} try", "%{count} tries", a) % {:count => a}]
79     }
80     if @winner
81       debug "replacing winner score of %d with %d" % [ret[@winner].first, t]
82       tries = ret[@winner].last
83       ret[@winner] = [t, _("winner, %{tries}") % {:tries => tries}]
84     end
85     return ret.sort_by { |h| h.last.first }.reverse
86   end
87
88 end
89
90 class AzGamePlugin < Plugin
91
92   def initialize
93     super
94     # if @registry.has_key?(:games)
95     #   @games = @registry[:games]
96     # else
97       @games = Hash.new
98     # end
99     if @registry.has_key?(:wordcache) and @registry[:wordcache]
100       @wordcache = @registry[:wordcache]
101     else
102       @wordcache = Hash.new
103     end
104     debug "A-Z wordcache: #{@wordcache.pretty_inspect}"
105
106     @rules = {
107       :italian => {
108       :good => /s\.f\.|s\.m\.|agg\.|v\.tr\.|v\.(pronom\.)?intr\./, # avv\.|pron\.|cong\.
109       :bad => /var\./,
110       :first => 'abaco',
111       :last => 'zuzzurellone',
112       :url => "http://www.demauroparavia.it/%s",
113       :wapurl => "http://wap.demauroparavia.it/index.php?lemma=%s",
114       :listener => /^[a-z]+$/
115     },
116     :english => {
117       :good => /(?:singular )?noun|verb|adj/,
118       :first => 'abacus',
119       :last => 'zuni',
120       :url => "http://www.chambersharrap.co.uk/chambers/features/chref/chref.py/main?query=%s&title=21st",
121       :listener => /^[a-z]+$/
122     },
123     }
124
125     japanese_wordlist = "#{@bot.botclass}/azgame/wordlist-japanese"
126     if File.exist?(japanese_wordlist)
127       words = File.readlines(japanese_wordlist) \
128                              .map {|line| line.strip} .uniq
129       if(words.length >= 4) # something to guess
130         @rules[:japanese] = {
131             :good => /^\S+$/,
132             :list => words,
133             :first => words[0],
134             :last => words[-1],
135             :listener => /^\S+$/
136         }
137         debug "Japanese wordlist loaded, #{@rules[:japanese][:list].length} lines; first word: #{@rules[:japanese][:first]}, last word: #{@rules[:japanese][:last]}"
138       end
139     end
140   end
141
142   def save
143     # @registry[:games] = @games
144     @registry[:wordcache] = @wordcache
145   end
146
147   def message(m)
148     return if m.channel.nil? or m.address?
149     k = m.channel.downcase.to_s # to_sym?
150     return unless @games.key?(k)
151     return if m.params
152     word = m.plugin.downcase
153     return unless word =~ @games[k].listener
154     word_check(m, k, word)
155   end
156
157   def word_check(m, k, word)
158     # Not really safe ... what happens
159     Thread.new {
160       isit = @games[k].check(word)
161       case isit.first
162       when :bingo
163         m.reply _("%{bold}BINGO!%{bold} the word was %{underline}%{word}%{underline}. Congrats, %{bold}%{player}%{bold}!") % {:bold => Bold, :underline => Underline, :word => word, :player => m.sourcenick}
164         @games[k].total_tries += 1
165         @games[k].tries[m.source] += 1
166         @games[k].winner = m.source
167         ar = @games[k].score.inject([]) { |res, kv|
168           res.push("%s: %d (%s)" % kv.flatten)
169         }
170         m.reply _("The game was won after %{tries} tries. Scores for this game:    %{scores}") % {:tries => @games[k].total_tries, :scores => ar.join('; ')}
171         @games.delete(k)
172       when :out
173         m.reply _("%{word} is not in the range %{bold}%{range}%{bold}") % {:word => word, :bold => Bold, :range => isit.last} if m.address?
174       when :noexist
175         # bail out early if the game was won in the mean time
176         return if !@games[k] or @games[k].winner
177         m.reply _("%{word} doesn't exist or is not acceptable for the game") % {:word => word}
178         @games[k].total_failed += 1
179         @games[k].failed[m.source] += 1
180       when :in
181         # bail out early if the game was won in the mean time
182         return if !@games[k] or @games[k].winner
183         m.reply _("close, but no cigar. New range: %{bold}%{range}%{bold}") % {:bold => Bold, :range => isit.last}
184         @games[k].total_tries += 1
185         @games[k].tries[m.source] += 1
186       when :ignore
187         m.reply _("%{word} is already one of the range extrema: %{range}") % {:word => word, :range => isit.last} if m.address?
188       else
189         m.reply _("hm, something went wrong while verifying %{word}")
190       end
191     }
192   end
193
194   def manual_word_check(m, params)
195     k = m.channel.downcase.to_s
196     word = params[:word].downcase
197     if not @games.key?(k)
198       m.reply _("no A-Z game running here, can't check if %{word} is valid, can I?")
199       return
200     end
201     if word !~ /^\S+$/
202       m.reply _("I only accept single words composed by letters only, sorry")
203       return
204     end
205     word_check(m, k, word)
206   end
207
208   def stop_game(m, params)
209     return if m.channel.nil? # Shouldn't happen, but you never know
210     k = m.channel.downcase.to_s # to_sym?
211     if @games.key?(k)
212       m.reply _("the word in %{bold}%{range}%{bold} was:   %{bold}%{word}%{bold}") % {:bold => Bold, :range => @games[k].range, :word => @games[k].word}
213       ar = @games[k].score.inject([]) { |res, kv|
214         res.push("%s: %d (%s)" % kv.flatten)
215       }
216       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('; ')}
217       @games.delete(k)
218     else
219       m.reply _("no A-Z game running in this channel ...")
220     end
221   end
222
223   def start_game(m, params)
224     return if m.channel.nil? # Shouldn't happen, but you never know
225     k = m.channel.downcase.to_s # to_sym?
226     unless @games.key?(k)
227       lang = (params[:lang] || @bot.config['core.language']).to_sym
228       method = 'random_pick_'+lang.to_s
229       m.reply _("let me think ...")
230       if @rules.has_key?(lang) and self.respond_to?(method)
231         word = self.send(method)
232         if word.empty?
233           m.reply _("couldn't think of anything ...")
234           return
235         end
236       else
237         m.reply _("I can't play A-Z in %{lang}, sorry") % {:lang => lang}
238         return
239       end
240       m.reply _("got it!")
241       @games[k] = AzGame.new(self, lang, @rules[lang], word)
242     end
243     tr = @games[k].total_tries
244     # this message building code is rewritten to make translation easier
245     if tr == 0
246       tr_msg = ''
247     else
248       f_tr = @games[k].total_failed
249       if f_tr > 0
250         tr_msg = _(" (after %{total_tries} and %{invalid_tries})") %
251            { :total_tries => n_("%{count} try", "%{count} tries", tr) %
252                              {:count => tr},
253              :invalid_tries => n_("%{count} invalid try", "%{count} invalid tries", tr) %
254                                {:count => f_tr} }
255       else
256         tr_msg = _(" (after %{total_tries})") %
257                  { :total_tries => n_("%{count} try", "%{count} tries", tr) %
258                              {:count => tr}}
259       end
260     end
261
262     m.reply _("A-Z: %{bold}%{range}%{bold}") % {:bold => Bold, :range => @games[k].range} + tr_msg
263     return
264   end
265
266   def wordlist(m, params)
267     pars = params[:params]
268     lang = (params[:lang] || @bot.config['core.language']).to_sym
269     wc = @wordcache[lang] || Hash.new rescue Hash.new
270     cmd = params[:cmd].to_sym rescue :count
271     case cmd
272     when :count
273       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}
274     when :show, :list
275       if pars.empty?
276         m.reply _("provide a regexp to match")
277         return
278       end
279       begin
280         regex = /#{pars[0]}/
281         matches = wc.keys.map { |k|
282           k.to_s
283         }.grep(regex)
284       rescue
285         matches = []
286       end
287       if matches.size == 0
288         m.reply _("no %{lang} word I know match %{pattern}") % {:lang => lang, :pattern => pars[0]}
289       elsif matches.size > 25
290         m.reply _("more than 25 %{lang} words I know match %{pattern}, try a stricter matching") % {:lang => lang, :pattern => pars[0]}
291       else
292         m.reply "#{matches.join(', ')}"
293       end
294     when :info
295       if pars.empty?
296         m.reply _("provide a word")
297         return
298       end
299       word = pars[0].downcase.to_sym
300       if not wc.key?(word)
301         m.reply _("I don't know any %{lang} word %{word}") % {:lang => lang, :word => word}
302         return
303       end
304       if wc[word].key?(:when)
305         tr = _("%{word} learned from %{user} on %{date}") % {:word => word, :user => wc[word][:who], :date => wc[word][:when]}
306       else
307         tr = _("%{word} learned from %{user}") % {:word => word, :user => wc[word][:who]} 
308       end
309       m.reply tr
310     when :delete 
311       if pars.empty?
312         m.reply _("provide a word")
313         return
314       end
315       word = pars[0].downcase.to_sym
316       if not wc.key?(word)
317         m.reply _("I don't know any %{lang} word %{word}") % {:lang => lang, :word => word}
318         return
319       end
320       wc.delete(word)
321       @bot.okay m.replyto
322     when :add
323       if pars.empty?
324         m.reply _("provide a word")
325         return
326       end
327       word = pars[0].downcase.to_sym
328       if wc.key?(word)
329         m.reply _("I already know the %{lang} word %{word}")
330         return
331       end
332       wc[word] = { :who => m.sourcenick, :when => Time.now }
333       @bot.okay m.replyto
334     else
335     end
336   end
337
338   def is_japanese?(word)
339     @rules[:japanese][:list].include?(word)
340   end
341
342   # return integer between min and max, inclusive
343   def rand_between(min, max)
344     rand(max - min + 1) + min
345   end
346
347   def random_pick_japanese(min=nil, max=nil)
348     rules = @rules[:japanese]
349     min = rules[:first] if min.nil_or_empty?
350     max = rules[:last]  if max.nil_or_empty?
351     debug "Randomly picking word between #{min} and #{max}"
352     min_index = rules[:list].index(min)
353     max_index = rules[:list].index(max)
354     debug "Index between #{min_index} and #{max_index}"
355     index = rand_between(min_index + 1, max_index - 1)
356     debug "Index generated: #{index}"
357     word = rules[:list][index]
358     debug "Randomly picked #{word}"
359     word
360   end
361
362   def is_italian?(word)
363     unless @wordcache.key?(:italian)
364       @wordcache[:italian] = Hash.new
365     end
366     wc = @wordcache[:italian]
367     return true if wc.key?(word.to_sym)
368     rules = @rules[:italian]
369     p = @bot.httputil.get(rules[:wapurl] % word, :open_timeout => 60, :read_timeout => 60)
370     if not p
371       error "could not connect!"
372       return false
373     end
374     debug p
375     p.scan(/<anchor>#{word} - (.*?)<go href="lemma.php\?ID=([^"]*?)"/) { |qual, url|
376       debug "new word #{word} of type #{qual}"
377       if qual =~ rules[:good] and qual !~ rules[:bad]
378         wc[word.to_sym] = {:who => :dict}
379         return true
380       end
381       next
382     }
383     return false
384   end
385
386   def random_pick_italian(min=nil,max=nil)
387     # Try to pick a random word between min and max
388     word = String.new
389     min = min.to_s
390     max = max.to_s
391     if min > max
392       m.reply "#{min} > #{max}"
393       return word
394     end
395     rules = @rules[:italian]
396     min = rules[:first] if min.empty?
397     max = rules[:last]  if max.empty?
398     debug "looking for word between #{min.inspect} and #{max.inspect}"
399     return word if min.empty? or max.empty?
400     begin
401       while (word <= min or word >= max or word !~ /^[a-z]+$/)
402         debug "looking for word between #{min} and #{max} (prev: #{word.inspect})"
403         # TODO for the time being, skip words with extended characters
404         unless @wordcache.key?(:italian)
405           @wordcache[:italian] = Hash.new
406         end
407         wc = @wordcache[:italian]
408
409         if wc.size > 0
410           cache_or_url = rand(2)
411           if cache_or_url == 0
412             debug "getting word from wordcache"
413             word = wc.keys[rand(wc.size)].to_s
414             next
415           end
416         end
417
418         # TODO when doing ranges, adapt this choice
419         l = ('a'..'z').to_a[rand(26)]
420         debug "getting random word from dictionary, starting with letter #{l}"
421         first = rules[:url] % "lettera_#{l}_0_50"
422         p = @bot.httputil.get(first)
423         max_page = p.match(/ \/ (\d+)<\/label>/)[1].to_i
424         pp = rand(max_page)+1
425         debug "getting random word from dictionary, starting with letter #{l}, page #{pp}"
426         p = @bot.httputil.get(first+"&pagina=#{pp}") if pp > 1
427         lemmi = Array.new
428         good = rules[:good]
429         bad =  rules[:bad]
430         # We look for a lemma composed by a single word and of length at least two
431         p.scan(/<li><a href="([^"]+?)" title="consulta il lemma ([^ "][^ "]+?)">.*?&nbsp;(.+?)<\/li>/) { |url, prelemma, tipo|
432           lemma = prelemma.downcase.to_sym
433           debug "checking lemma #{lemma} (#{prelemma}) of type #{tipo} from url #{url}"
434           next if wc.key?(lemma)
435           case tipo
436           when good
437             if tipo =~ bad
438               debug "refusing, #{bad}"
439               next
440             end
441             debug "good one"
442             lemmi << lemma
443             wc[lemma] = {:who => :dict}
444           else
445             debug "refusing, not #{good}"
446           end
447         }
448         word = lemmi[rand(lemmi.length)].to_s
449       end
450     rescue => e
451       error "error #{e.inspect} while looking up a word"
452       error e.backtrace.join("\n")
453     end
454     return word
455   end
456
457   def is_english?(word)
458     unless @wordcache.key?(:english)
459       @wordcache[:english] = Hash.new
460     end
461     wc = @wordcache[:english]
462     return true if wc.key?(word.to_sym)
463     rules = @rules[:english]
464     p = @bot.httputil.get(rules[:url] % CGI.escape(word))
465     if not p
466       error "could not connect!"
467       return false
468     end
469     debug p
470     if p =~ /<span class="(?:hwd|srch)">#{word}<\/span>([^\n]+?)<span class="psa">#{rules[:good]}<\/span>/i
471       debug "new word #{word}"
472         wc[word.to_sym] = {:who => :dict}
473         return true
474     end
475     return false
476   end
477
478   def random_pick_english(min=nil,max=nil)
479     # Try to pick a random word between min and max
480     word = String.new
481     min = min.to_s
482     max = max.to_s
483     if min > max
484       m.reply "#{min} > #{max}"
485       return word
486     end
487     rules = @rules[:english]
488     min = rules[:first] if min.empty?
489     max = rules[:last]  if max.empty?
490     debug "looking for word between #{min.inspect} and #{max.inspect}"
491     return word if min.empty? or max.empty?
492     begin
493       while (word <= min or word >= max or word !~ /^[a-z]+$/)
494         debug "looking for word between #{min} and #{max} (prev: #{word.inspect})"
495         # TODO for the time being, skip words with extended characters
496         unless @wordcache.key?(:english)
497           @wordcache[:english] = Hash.new
498         end
499         wc = @wordcache[:english]
500
501         if wc.size > 0
502           cache_or_url = rand(2)
503           if cache_or_url == 0
504             debug "getting word from wordcache"
505             word = wc.keys[rand(wc.size)].to_s
506             next
507           end
508         end
509
510         # TODO when doing ranges, adapt this choice
511         l = ('a'..'z').to_a[rand(26)]
512         ll = ('a'..'z').to_a[rand(26)]
513         random = [l,ll].join('*') + '*'
514         debug "getting random word from dictionary, matching #{random}"
515         p = @bot.httputil.get(rules[:url] % CGI.escape(random))
516         debug p
517         lemmi = Array.new
518         good = rules[:good]
519         # We look for a lemma composed by a single word and of length at least two
520         p.scan(/<span class="(?:hwd|srch)">(.*?)<\/span>([^\n]+?)<span class="psa">#{rules[:good]}<\/span>/i) { |prelemma, discard|
521           lemma = prelemma.downcase
522           debug "checking lemma #{lemma} (#{prelemma}) and discarding #{discard}"
523           next if wc.key?(lemma.to_sym)
524           if lemma =~ /^[a-z]+$/
525             debug "good one"
526             lemmi << lemma
527             wc[lemma.to_sym] = {:who => :dict}
528           else
529             debug "funky characters, not good"
530           end
531         }
532         next if lemmi.empty?
533         word = lemmi[rand(lemmi.length)]
534       end
535     rescue => e
536       error "error #{e.inspect} while looking up a word"
537       error e.backtrace.join("\n")
538     end
539     return word
540   end
541
542   def help(plugin, topic="")
543     case topic
544     when 'manage'
545       return _("az [lang] word [count|list|add|delete] => manage the az wordlist for language lang (defaults to current bot language)")
546     when 'cancel'
547       return _("az cancel => abort current game")
548     when 'check'
549       return _('az check <word> => checks <word> against current game')
550     when 'rules'
551       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")
552     when 'play'
553       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")
554     end
555     return _("az topics: play, rules, cancel, manage, check")
556   end
557
558 end
559
560 plugin = AzGamePlugin.new
561 plugin.map 'az [:lang] word :cmd *params', :action=>'wordlist', :defaults => { :lang => nil, :cmd => 'count', :params => [] }, :auth_path => '!az::edit!'
562 plugin.map 'az cancel', :action=>'stop_game', :private => false
563 plugin.map 'az check :word', :action => 'manual_word_check', :private => false
564 plugin.map 'az [play] [:lang]', :action=>'start_game', :private => false, :defaults => { :lang => nil }
565