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