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