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