]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - data/rbot/plugins/games/hangman.rb
hangman: make replies more compatible with gettext
[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 #{RandomWord::SITE}\n"+
249              "hangman play with wordlist <wordlist> => hangman with random word from <wordlist>"
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 #{Bold}again#{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'