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