]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - data/rbot/plugins/games/azgame.rb
6fb3f670d3d3bc963c8aaf9ef7aceedcecae505b
[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     if @rules[:list]
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
43         @check_method = nil
44       end
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]}"
50             begin
51               File.open(@rules[:addfile], "a") do |f|
52                 f.puts w
53               end
54             rescue Exception => e
55               error "failed to add #{w} to #{@rules[:addfile]}"
56               error e
57             end
58           end
59         end
60         wl
61       end
62     else
63       @check_method = "is_#{@lang}?"
64       @check = Proc.new { |w| @plugin.send(@check_method, w) }
65     end
66   end
67
68   def check(word)
69     w = word.downcase
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)
79     debug "we like it"
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)
85       return [:in, @range]
86     elsif w > @word and w < @range.last
87       @range.last.replace(w)
88       return [:in, @range]
89     end
90     return [:out, @range]
91   end
92
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
96
97   include Math
98
99   def score
100     n = @total_tries
101     p = @tries.keys.length
102     t = (100*exp(-((n-1)**2)/(50.0**2))).ceil + p
103     debug "Total score: #{t}"
104     ret = Hash.new
105     @tries.each { |k, a|
106       ret[k] = [t*a/n, n_("%{count} try", "%{count} tries", a) % {:count => a}]
107     }
108     if @winner
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}]
112     end
113     return ret.sort_by { |h| h.last.first }.reverse
114   end
115
116 end
117
118 class AzGamePlugin < Plugin
119
120   def initialize
121     super
122     # if @registry.has_key?(:games)
123     #   @games = @registry[:games]
124     # else
125       @games = Hash.new
126     # end
127     if @registry.has_key?(:wordcache) and @registry[:wordcache]
128       @wordcache = @registry[:wordcache]
129     else
130       @wordcache = Hash.new
131     end
132     debug "A-Z wordcache: #{@wordcache.pretty_inspect}"
133
134     @rules = {
135       :italian => {
136       :good => /s\.f\.|s\.m\.|agg\.|v\.tr\.|v\.(pronom\.)?intr\./, # avv\.|pron\.|cong\.
137       :bad => /var\./,
138       :first => 'abaco',
139       :last => 'zuzzurellone',
140       :url => "http://www.demauroparavia.it/%s",
141       :wapurl => "http://wap.demauroparavia.it/index.php?lemma=%s",
142       :listener => /^[a-z]+$/
143     },
144     :english => {
145       :good => /(?:singular )?noun|verb|adj/,
146       :first => 'abacus',
147       :last => 'zuni',
148       :url => "http://www.chambersharrap.co.uk/chambers/features/chref/chref.py/main?query=%s&title=21st",
149       :listener => /^[a-z]+$/
150     },
151     }
152
153     @wordlist_base = "#{@bot.botclass}/azgame/wordlist-"
154     @autoadd_base = "#{@bot.botclass}/azgame/autoadd-"
155   end
156
157   def initialize_wordlist(params)
158     lang = params[:lang]
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}
167       end
168       words.uniq!
169       words.sort!
170       if(words.length >= 4) # something to guess
171         rules = {
172             :good => /^\S+$/,
173             :list => words,
174             :first => words[0],
175             :last => words[-1],
176             :addlang => addlang,
177             :addfile => autoadd,
178             :listener => /^\S+$/
179         }
180         debug "#{lang} wordlist loaded, #{rules[:list].length} lines; first word: #{rules[:first]}, last word: #{rules[:last]}"
181         return rules
182       end
183     end
184     return false
185   end
186
187   def save
188     # @registry[:games] = @games
189     @registry[:wordcache] = @wordcache
190   end
191
192   def message(m)
193     return if m.channel.nil? or m.address?
194     k = m.channel.downcase.to_s # to_sym?
195     return unless @games.key?(k)
196     return if m.params
197     word = m.plugin.downcase
198     return unless word =~ @games[k].listener
199     word_check(m, k, word)
200   end
201
202   def word_check(m, k, word)
203     # Not really safe ... what happens
204     Thread.new {
205       isit = @games[k].check(word)
206       case isit.first
207       when :bingo
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)
214         }
215         m.reply _("The game was won after %{tries} tries. Scores for this game:    %{scores}") % {:tries => @games[k].total_tries, :scores => ar.join('; ')}
216         @games.delete(k)
217       when :out
218         m.reply _("%{word} is not in the range %{bold}%{range}%{bold}") % {:word => word, :bold => Bold, :range => isit.last} if m.address?
219       when :noexist
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
225       when :in
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
231       when :ignore
232         m.reply _("%{word} is already one of the range extrema: %{range}") % {:word => word, :range => isit.last} if m.address?
233       else
234         m.reply _("hm, something went wrong while verifying %{word}")
235       end
236     }
237   end
238
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?")
244       return
245     end
246     if word !~ /^\S+$/
247       m.reply _("I only accept single words composed by letters only, sorry")
248       return
249     end
250     word_check(m, k, word)
251   end
252
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?
256     if @games.key?(k)
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)
260       }
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('; ')}
262       @games.delete(k)
263     else
264       m.reply _("no A-Z game running in this channel ...")
265     end
266   end
267
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)
277         if word.empty?
278           m.reply _("couldn't think of anything ...")
279           return
280         end
281         m.reply _("got it!")
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)
285         if word.empty?
286           m.reply _("couldn't think of anything ...")
287           return
288         end
289         m.reply _("got it!")
290         @games[k] = AzGame.new(self, lang, rules, word)
291       else
292         m.reply _("I can't play A-Z in %{lang}, sorry") % {:lang => lang}
293         return
294       end
295     end
296     tr = @games[k].total_tries
297     # this message building code is rewritten to make translation easier
298     if tr == 0
299       tr_msg = ''
300     else
301       f_tr = @games[k].total_failed
302       if f_tr > 0
303         tr_msg = _(" (after %{total_tries} and %{invalid_tries})") %
304            { :total_tries => n_("%{count} try", "%{count} tries", tr) %
305                              {:count => tr},
306              :invalid_tries => n_("%{count} invalid try", "%{count} invalid tries", tr) %
307                                {:count => f_tr} }
308       else
309         tr_msg = _(" (after %{total_tries})") %
310                  { :total_tries => n_("%{count} try", "%{count} tries", tr) %
311                              {:count => tr}}
312       end
313     end
314
315     m.reply _("A-Z: %{bold}%{range}%{bold}") % {:bold => Bold, :range => @games[k].range} + tr_msg
316     return
317   end
318
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
324     case cmd
325     when :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}
327     when :show, :list
328       if pars.empty?
329         m.reply _("provide a regexp to match")
330         return
331       end
332       begin
333         regex = /#{pars[0]}/
334         matches = wc.keys.map { |k|
335           k.to_s
336         }.grep(regex)
337       rescue
338         matches = []
339       end
340       if matches.size == 0
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]}
344       else
345         m.reply "#{matches.join(', ')}"
346       end
347     when :info
348       if pars.empty?
349         m.reply _("provide a word")
350         return
351       end
352       word = pars[0].downcase.to_sym
353       if not wc.key?(word)
354         m.reply _("I don't know any %{lang} word %{word}") % {:lang => lang, :word => word}
355         return
356       end
357       if wc[word].key?(:when)
358         tr = _("%{word} learned from %{user} on %{date}") % {:word => word, :user => wc[word][:who], :date => wc[word][:when]}
359       else
360         tr = _("%{word} learned from %{user}") % {:word => word, :user => wc[word][:who]} 
361       end
362       m.reply tr
363     when :delete 
364       if pars.empty?
365         m.reply _("provide a word")
366         return
367       end
368       word = pars[0].downcase.to_sym
369       if not wc.key?(word)
370         m.reply _("I don't know any %{lang} word %{word}") % {:lang => lang, :word => word}
371         return
372       end
373       wc.delete(word)
374       @bot.okay m.replyto
375     when :add
376       if pars.empty?
377         m.reply _("provide a word")
378         return
379       end
380       word = pars[0].downcase.to_sym
381       if wc.key?(word)
382         m.reply _("I already know the %{lang} word %{word}")
383         return
384       end
385       wc[word] = { :who => m.sourcenick, :when => Time.now }
386       @bot.okay m.replyto
387     else
388     end
389   end
390
391   # return integer between min and max, inclusive
392   def rand_between(min, max)
393     rand(max - min + 1) + min
394   end
395
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}"
407     word
408   end
409
410   def is_italian?(word)
411     unless @wordcache.key?(:italian)
412       @wordcache[:italian] = Hash.new
413     end
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)
418     if not p
419       error "could not connect!"
420       return false
421     end
422     debug p
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}
427         return true
428       end
429       next
430     }
431     return false
432   end
433
434   def random_pick_italian(min=nil,max=nil)
435     # Try to pick a random word between min and max
436     word = String.new
437     min = min.to_s
438     max = max.to_s
439     if min > max
440       m.reply "#{min} > #{max}"
441       return word
442     end
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?
448     begin
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
454         end
455         wc = @wordcache[:italian]
456
457         if wc.size > 0
458           cache_or_url = rand(2)
459           if cache_or_url == 0
460             debug "getting word from wordcache"
461             word = wc.keys[rand(wc.size)].to_s
462             next
463           end
464         end
465
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
475         lemmi = Array.new
476         good = rules[:good]
477         bad =  rules[:bad]
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 ([^ "][^ "]+?)">.*?&nbsp;(.+?)<\/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)
483           case tipo
484           when good
485             if tipo =~ bad
486               debug "refusing, #{bad}"
487               next
488             end
489             debug "good one"
490             lemmi << lemma
491             wc[lemma] = {:who => :dict}
492           else
493             debug "refusing, not #{good}"
494           end
495         }
496         word = lemmi[rand(lemmi.length)].to_s
497       end
498     rescue => e
499       error "error #{e.inspect} while looking up a word"
500       error e.backtrace.join("\n")
501     end
502     return word
503   end
504
505   def is_english?(word)
506     unless @wordcache.key?(:english)
507       @wordcache[:english] = Hash.new
508     end
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))
513     if not p
514       error "could not connect!"
515       return false
516     end
517     debug p
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}
521         return true
522     end
523     return false
524   end
525
526   def random_pick_english(min=nil,max=nil)
527     # Try to pick a random word between min and max
528     word = String.new
529     min = min.to_s
530     max = max.to_s
531     if min > max
532       m.reply "#{min} > #{max}"
533       return word
534     end
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?
540     begin
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
546         end
547         wc = @wordcache[:english]
548
549         if wc.size > 0
550           cache_or_url = rand(2)
551           if cache_or_url == 0
552             debug "getting word from wordcache"
553             word = wc.keys[rand(wc.size)].to_s
554             next
555           end
556         end
557
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))
564         debug p
565         lemmi = Array.new
566         good = rules[:good]
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]+$/
573             debug "good one"
574             lemmi << lemma
575             wc[lemma.to_sym] = {:who => :dict}
576           else
577             debug "funky characters, not good"
578           end
579         }
580         next if lemmi.empty?
581         word = lemmi[rand(lemmi.length)]
582       end
583     rescue => e
584       error "error #{e.inspect} while looking up a word"
585       error e.backtrace.join("\n")
586     end
587     return word
588   end
589
590   def help(plugin, topic="")
591     case topic
592     when 'manage'
593       return _("az [lang] word [count|list|add|delete] => manage the az wordlist for language lang (defaults to current bot language)")
594     when 'cancel'
595       return _("az cancel => abort current game")
596     when 'check'
597       return _('az check <word> => checks <word> against current game')
598     when 'rules'
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")
600     when 'play'
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")
602     end
603     offset = @wordlist_base.length
604     langs = @rules.keys
605     wls = Dir.glob(@wordlist_base + "*").map { |f| f[offset,f.length].intern rescue nil }.compact - langs
606     return [
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(", ") },
610     ].compact.join(". ")
611
612   end
613
614 end
615
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 }
621