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