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