]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - data/rbot/plugins/games/hangman.rb
hangman plugin: don't fail silently on !hangman
[user/henk/code/ruby/rbot.git] / data / rbot / plugins / games / hangman.rb
1 #-- vim:sw=2:et
2 #++
3 #
4 # :title: Hangman Plugin
5 #
6 # Author:: Raine Virta <rane@kapsi.fi>
7 # Copyright:: (C) 2009 Raine Virta
8 # License:: GPL v2
9 #
10 # Description:: Hangman game for rbot
11 #
12 # TODO:: some sort of turn-basedness, maybe
13
14 module RandomWord
15   SITE = "http://coyotecult.com/tools/randomwordgenerator.php"
16
17   def self.get(count=1)
18     res = Net::HTTP.post_form(URI.parse(SITE), {'numwords' => count})
19     raise _("random word generator site failed with #{res.code} - #{res.message}") unless Net::HTTPSuccess === res
20     words = res.body.scan(%r{<a.*?\?w=(.*?)\n}).flatten
21
22     count == 1 ? words.first : words
23   end
24 end
25
26 module Google
27   URL   = "http://www.google.com/wml/search?hl=en&q=define:"
28   REGEX = %r{Web definitions for .*?<br/>(.*?)<br/>}
29
30   def self.define(phrase)
31     raw = Net::HTTP.get(URI.parse(URL+CGI.escape(phrase)))
32     res = raw.scan(REGEX).flatten.map { |e| e.ircify_html }
33
34     res.empty? ? false : res.last
35   end
36 end
37
38 class Hangman
39   LETTER_VALUE = 5
40
41   module Scoring
42     def self.correct_word_guess(game)
43       # (2 - (amt of visible chars / word length)) * (amt of chars not visible * 5)
44       length    = game.word.size
45       visible   = game.visible_characters.size
46       invisible = length - visible
47       score     = (2-(visible/length.to_f))*(invisible*5)
48       score    *= 1.5
49       score.round
50     end
51
52     def self.incorrect_word_guess(game)
53       incorrect_letter(game)
54     end
55
56     def self.correct_letter(game)
57       ((1/Math.log(game.word.size))+1) * LETTER_VALUE
58     end
59
60     def self.incorrect_letter(game)
61       Math.log(game.word.size) * -LETTER_VALUE
62     end
63   end
64 end
65
66 class Hangman
67   attr_reader :misses, :guesses, :word, :scores
68
69   STAGES = [' (x_x) ', ' (;_;) ', ' (>_<) ', ' (-_-) ', ' (o_~) ', ' (^_^) ', '\(^o^)/']
70   HEALTH = STAGES.size-1
71   LETTER = /[^\W0-9_]/u
72
73   def initialize(word)
74     @word     = word
75     @guesses  = []
76     @misses   = []
77     @health   = HEALTH
78     @canceled = false
79     @solved   = false
80     @scores   = {}
81   end
82
83   def visible_characters
84     # array of visible characters
85     characters.reject { |c| !@guesses.include?(c) && c =~ LETTER }
86   end
87
88   def letters
89     # array of the letters in the word
90     characters.reject { |c| c !~ LETTER  }.map { |c| c.downcase }
91   end
92
93   def characters
94     @word.split(//u)
95   end
96
97   def face
98     STAGES[@health]
99   end
100
101   def to_s
102     # creates a string that presents the word with unknown letters shown as underscores
103     characters.map { |c|
104       @guesses.include?(c.downcase) || c !~ LETTER  ? c : "_"
105     }.join
106   end
107
108   def guess(player, str)
109     @scores[player] ||= 0
110
111     str.downcase!
112     # full word guess
113     if str !~ /^#{LETTER}$/u
114       if word.downcase == str
115         @scores[player] += Scoring::correct_word_guess(self)
116         @solved = true
117       else
118         @scores[player] += Scoring::incorrect_word_guess(self)
119         punish
120       end
121     else # single letter guess
122       return false if @guesses.include?(str) # letter has been guessed before
123
124       unless letters.include?(str)
125         @scores[player] += Scoring::incorrect_letter(self)
126         @misses << str
127         punish
128       else
129         @scores[player] += Scoring::correct_letter(self)
130       end
131
132       @guesses << str
133     end
134
135     return true
136   end
137
138   def over?
139     won? || lost? || @canceled
140   end
141
142   def won?
143     (letters - @guesses).empty? || @solved
144   end
145
146   def lost?
147     @health.zero?
148   end
149
150   def punish
151     @health -= 1
152   end
153
154   def cancel
155     @canceled = true
156   end
157 end
158
159 class GameManager
160   def initialize
161     @games = {}
162   end
163
164   def current(target)
165     game = all(target).last
166     game if game && !game.over?
167   end
168
169   def all(target)
170     @games[target] || @games[target] = []
171   end
172
173   def previous(target)
174     all(target).select { |game| game.over? }.last
175   end
176
177   def new(game)
178     all(game.channel) << game
179   end
180 end
181
182 define_structure :HangmanPlayerStats, :played, :score
183 define_structure :HangmanPrivateStats, :played, :score
184
185 class StatsHandler
186   def initialize(registry)
187     @registry = registry
188   end
189
190   def save_gamestats(game)
191     target = game.channel
192
193     if target.is_a?(User)
194       stats = priv_reg[target]
195       stats.played += 1
196       stats.score  += game.scores.values.last.round
197       priv_reg[target] = stats
198     elsif target.is_a?(Channel)
199       stats = chan_stats(target)
200       stats['played'] += 1
201
202       reg = player_stats(target)
203       game.scores.each do |user, score|
204         pstats = reg[user]
205         pstats.played += 1
206         pstats.score  += score.round
207         reg[user] = pstats
208       end
209     end
210   end
211
212   def player_stats(channel)
213     reg = chan_reg(channel).sub_registry('player')
214     reg.set_default(HangmanPlayerStats.new(0,0))
215     reg
216   end
217
218   def chan_stats(channel)
219     reg = chan_reg(channel).sub_registry('stats')
220     reg.set_default(0)
221     reg
222   end
223
224   def chan_reg(channel)
225     @registry.sub_registry(channel.downcase)
226   end
227
228   def priv_reg
229     reg = @registry.sub_registry('private')
230     reg.set_default(HangmanPrivateStats.new(0,0))
231     reg
232   end
233 end
234
235 class HangmanPlugin < Plugin
236   def initialize
237     super
238     @games = GameManager.new
239     @stats = StatsHandler.new(@registry)
240     @settings = {}
241   end
242
243   def help(plugin, topic="")
244     case topic
245     when "play"
246       return [_("hangman play on <channel> with word <word> => use in private chat with the bot to start a game with custom word\n"),
247               _("hangman play random [with [max|min] length [<|>|== <length>]] => hangman with a random word from %{site}\n"),
248               _("hangman play with wordlist <wordlist> => hangman with random word from <wordlist>")].join % { :site => RandomWord::SITE }
249     when "stop"
250       return _("hangman stop => quits the current game")
251     when "define"
252       return _("hangman define => seeks a definition for the previous answer using google")
253     else
254       return _("hangman game plugin - topics: play, stop, define")
255     end
256   end
257
258   def get_word(params)
259     if params[:word]
260       params[:word].join(" ")
261     elsif params[:wordlist]
262       begin
263         wordlist = Wordlist.get(params[:wordlist].join("/"), :spaces => true)
264       rescue
265         raise _("no such wordlist")
266       end
267
268       wordlist[rand(wordlist.size)]
269     else # getting a random word
270       words = RandomWord::get(100)
271
272       if adj = params[:adj]
273         words = words.sort_by { |e| e.size }
274
275         if adj == "max"
276           words.last
277         else
278           words.first
279         end
280       elsif params[:relation] && params[:size]
281         words = words.select { |w| w.size.send(params[:relation], params[:size].to_i) }
282
283         unless words.empty?
284           words.first
285         else
286           m.reply _("suitable word not found in the set")
287           nil
288         end
289       else
290         words.first
291       end
292     end
293   end
294
295   def start(m, params)
296     begin
297       word = get_word(params) || return
298     rescue => e
299       m.reply e.message
300       return
301     end
302
303     if params[:channel] || m.public?
304       target = if m.public?
305         m.channel
306       else
307         @bot.server.channel(params[:channel])
308       end
309
310       # is the bot on the channel?
311       unless @bot.myself.channels.include?(target)
312         m.reply _("i'm not on that channel")
313         return
314       end
315
316       if @games.current(target)
317         m.reply _("there's already a hangman game in progress on the channel")
318         return
319       end
320
321       @bot.say target, _("%{nick} has started a hangman -- join the fun!") % {
322         :nick => m.source
323       }
324     else
325       target = m.source
326     end
327
328     game = Hangman.new(word)
329
330     class << game = Hangman.new(word)
331       attr_accessor :channel
332     end
333
334     game.channel = target
335
336     @games.new(game)
337     @settings[target] = params
338
339     @bot.say target, game_status(@games.current(target))
340   end
341
342   def stop(m, params)
343     target = m.replyto
344     if game = @games.current(target)
345       @bot.say target, _("oh well, the answer would've been %{answer}") % {
346         :answer => Bold + game.word + Bold
347       }
348
349       game.cancel
350       @stats.save_gamestats(game)
351     else
352       @bot.say target, _("no ongoing game")
353     end
354   end
355
356   def message(m)
357     target = m.replyto
358
359     if game = @games.current(target)
360       return unless m.message =~ /^[^\W0-9_]$/u || m.message =~ prepare_guess_regex(game)
361
362       if game.guess(m.source, m.message)
363         m.reply game_status(game)
364       else
365         return
366       end
367
368       if game.over?
369         sentence = if game.won?
370           _("you nailed it!")
371         elsif game.lost?
372           _("you've killed the poor guy :(")
373         end
374
375         again = _("go %{b}again%{b}?") % { :b => Bold }
376
377         scores = []
378         game.scores.each do |user, score|
379           str = "#{user.nick}: "
380           str << if score > 0
381             Irc.color(:green)+'+'
382           elsif score < 0
383             Irc.color(:brown)
384           end.to_s
385
386           str << score.round.to_s
387           str << Irc.color
388
389           scores << str
390         end
391
392         m.reply _("%{sentence} %{again} %{scores}") % {
393           :sentence => sentence, :again => again, :scores => scores.join(' ')
394         }, :nick => true
395
396         if rand(5).zero?
397           m.reply _("wondering what that means? try ´%{prefix}hangman define´") % {
398             :prefix => @bot.config['core.address_prefix'].first
399           }
400         end
401
402         @stats.save_gamestats(game)
403       end
404     elsif @settings[target] && m.message =~ /^(?:again|more!?$)/i
405       start(m, @settings[target])
406     end
407   end
408
409   def prepare_guess_regex(game)
410     Regexp.new("^#{game.characters.map { |c|
411       game.guesses.include?(c) || c !~ Hangman::LETTER ? c : '[^\W0-9_]'
412     }.join("")}$")
413   end
414
415   def game_status(game)
416     str = "%{word} %{face}" % {
417       :word   => game.over? ? "#{Bold}#{game.word}#{Bold}" : game.to_s,
418       :face   => game.face,
419       :misses => game.misses.map { |e| e.upcase }.join(" ")
420     }
421
422     str << " %{misses}" % {
423       :misses => game.misses.map { |e| e.upcase }.join(" ")
424     } unless game.misses.empty?
425
426     str
427   end
428
429   def score(m, params)
430     target = m.replyto
431
432     unless params[:nick]
433       stats = if m.private?
434         @stats.priv_reg[target]
435       else
436         @stats.player_stats(target)[m.source]
437       end
438
439       unless stats.played.zero?
440         m.reply _("you got %{score} points after %{games} games") % {
441           :score => stats.score.round,
442           :games => stats.played
443         }
444       else
445         m.reply _("you haven't played hangman, how about playing it right now? :)")
446       end
447     else
448       return unless m.public?
449
450       nick = params[:nick]
451       stats = @stats.player_stats(target)[nick]
452
453       unless stats.played.zero?
454         m.reply _("%{nick} has %{score} points after %{games} games") % {
455           :nick  => nick,
456           :score => stats.score.round,
457           :games => stats.played
458         }
459       else
460         m.reply _("%{nick} hasn't played hangman :(") % {
461           :nick => nick
462         }
463       end
464     end
465   end
466
467   def stats(m, params)
468     target = m.replyto
469     stats  = @stats.chan_stats(target)
470
471     if m.public?
472       m.reply _("%{games} games have been played on %{channel}") % {
473         :games   => stats['played'],
474         :channel => target.to_s
475       }
476     else
477       score(m, params)
478     end
479   end
480
481   def define(m, params)
482     if game = @games.previous(m.replyto)
483       if res = Google.define(game.word)
484         m.reply "#{Bold}#{game.word}#{Bold} -- #{res}"
485       else
486         m.reply _("looks like google has no definition for %{word}") % { :word => game.word }
487       end
488     else
489       m.reply _("no hangman game was played here recently, what do you want me to define?")
490     end
491   end
492 end
493
494 plugin = HangmanPlugin.new
495 plugin.map "hangman [play] with wordlist *wordlist", :action => 'start'
496 plugin.map "hangman [play] on :channel with word *word", :action => 'start'
497 plugin.map "hangman [play] [random] [with [:adj] length [:relation :size]]",
498   :action => 'start',
499   :requirements => { :adj => /min|max/, :relation => /<|<=|>=|>|==/, :size => /\d+/ }
500
501 plugin.map "hangman stop", :action => 'stop'
502
503 plugin.map "hangman score [:nick]", :action => 'score'
504 plugin.map "hangman stats", :action => 'stats'
505 plugin.map "hangman define", :action => 'define'