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