]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - data/rbot/plugins/games/hangman.rb
hangman plugin: rudimentary stats tracking along with some other enhancements
[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         str = if game.won?
369           _("you nailed it!")
370         elsif game.lost?
371           _("you've killed the poor guy :(")
372         end
373
374         str << _(" go #{Bold}again#{Bold}?")
375
376         game.scores.each do |user, score|
377           str << " #{user.nick}: "
378           str << if score > 0
379             Irc.color(:green)+'+'
380           elsif score < 0
381             Irc.color(:brown)
382           end.to_s
383
384           str << score.round.to_s
385           str << Irc.color
386         end
387
388         m.reply str, :nick => true
389
390         if rand(5).zero?
391           m.reply _("wondering what that means? try ´%{prefix}define´") % {
392             :prefix => @bot.config['core.address_prefix']
393           }
394         end
395
396         @stats.save_gamestats(game)
397       end
398     elsif @settings[target] && m.message =~ /^(?:again|more!?$)/i
399       start(m, @settings[target])
400     end
401   end
402
403   def prepare_guess_regex(game)
404     Regexp.new("^#{game.characters.map { |c|
405       game.guesses.include?(c) || c !~ Hangman::LETTER ? c : '[^\W0-9_]'
406     }.join("")}$")
407   end
408
409   def game_status(game)
410     str = "%{word} %{face}" % {
411       :word   => game.over? ? "#{Bold}#{game.word}#{Bold}" : game.to_s,
412       :face   => game.face,
413       :misses => game.misses.map { |e| e.upcase }.join(" ")
414     }
415
416     str << " %{misses}" % {
417       :misses => game.misses.map { |e| e.upcase }.join(" ")
418     } unless game.misses.empty?
419
420     str
421   end
422
423   def score(m, params)
424     target = m.replyto
425
426     unless params[:nick]
427       stats = if m.private?
428         @stats.priv_reg[target]
429       else
430         @stats.player_stats(target)[m.source]
431       end
432
433       unless stats.played.zero?
434         m.reply _("you got %{score} points after %{games} games") % {
435           :score => stats.score.round,
436           :games => stats.played
437         }
438       else
439         m.reply _("you haven't played hangman, how about playing it right now? :)")
440       end
441     else
442       return unless m.public?
443
444       user  = m.server.get_user(params[:nick])
445       stats = @stats.player_stats(target)[user]
446
447       unless stats.played.zero?
448         m.reply _("%{nick} has %{score} points after %{games} games") % {
449           :nick  => user.nick,
450           :score => stats.score.round,
451           :games => stats.played
452         }
453       else
454         m.reply _("%{nick} hasn't played hangman :(") % {
455           :nick => user.nick
456         }
457       end
458     end
459   end
460
461   def stats(m, params)
462     target = m.replyto
463     stats  = @stats.chan_stats(target)
464
465     if m.public?
466       m.reply _("%{games} games have been played on %{channel}") % {
467         :games   => stats['played'],
468         :channel => target.to_s
469       }
470     else
471       score(m, params)
472     end
473   end
474
475   def define(m, params)
476     if game = @games.previous(m.replyto)
477       return unless res = Google.define(game.word)
478       m.reply "#{Bold}#{game.word}#{Bold} -- #{res}"
479     end
480   end
481 end
482
483 plugin = HangmanPlugin.new
484 plugin.map "hangman [play] with wordlist *wordlist", :action => 'start'
485 plugin.map "hangman [play] on :channel with word *word", :action => 'start'
486 plugin.map "hangman [play] [random] [with [:adj] length [:relation :size]]",
487   :action => 'start',
488   :requirements => { :adj => /min|max/, :relation => /<|<=|>=|>|==/, :size => /\d+/ }
489
490 plugin.map "hangman stop", :action => 'stop'
491
492 plugin.map "hangman score [:nick]", :action => 'score'
493 plugin.map "hangman stats", :action => 'stats'
494 plugin.map "define", :action => 'define'