]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - data/rbot/plugins/games/wheelfortune.rb
7e81895378f288dc78703030e86cd6bed9a20031
[user/henk/code/ruby/rbot.git] / data / rbot / plugins / games / wheelfortune.rb
1 #-- vim:sw=2:et
2 #++
3 #
4 # :title: Hangman/Wheel Of Fortune
5 #
6 # Author:: Giuseppe "Oblomov" Bilotta <giuseppe.bilotta@gmail.com>
7 # Copyright:: (C) 2007 Giuseppe Bilotta
8 # License:: rbot
9
10 # Wheel-of-Fortune Question/Answer
11 class WoFQA
12   attr_accessor :cat, :clue, :hint
13   attr_reader :answer
14   attr_accessor :guessed
15   def initialize(cat, clue, ans=nil)
16     @cat = cat # category
17     @clue = clue # clue phrase
18     self.answer = ans
19     @guessed = false
20   end
21
22   def guessed?
23     @guessed
24   end
25
26   def catclue
27     ret = ""
28     ret << "(" + cat + ") " unless cat.empty?
29     ret << clue
30   end
31
32   def answer=(ans)
33     if !ans
34       @answer = nil
35       @split = []
36       @hint = []
37       return
38     end
39     @answer = ans.dup.downcase
40     @split = @answer.scan(/./u)
41     @hint = @split.inject([]) { |list, ch|
42       if ch !~ /[a-z]/
43         list << ch
44       else
45         list << "*"
46       end
47     }
48     @used = []
49   end
50
51   def announcement
52     ret = self.catclue
53     if !@used.empty?
54       ret << _(" [Letters called so far: %{red}%{letters}%{nocolor}]") % {
55         :red => Irc.color(:red),
56         :letters => @used.join(" "),
57         :nocolor => Irc.color()
58       }
59     end
60     ret << "\n"
61     ret << @hint.join
62   end
63
64   def check(ans_or_letter)
65     d = ans_or_letter.downcase
66     if d == @answer
67       return :gotit
68     elsif d.length == 1
69       if @used.include?(d)
70         return :used
71       else
72         @used << d
73         @used.sort!
74         if @split.include?(d)
75           count = 0
76           @split.each_with_index { |c, i|
77             if c == d
78               @hint[i] = d.upcase
79               count += 1
80             end
81           }
82           return count
83         else
84           return :missing
85         end
86       end
87     else
88       return :wrong
89     end
90   end
91
92 end
93
94 # Wheel-of-Fortune game
95 class WoFGame
96   attr_reader :name, :manager, :single, :max, :pending
97   attr_writer :running
98   attr_accessor :must_buy, :price
99   def initialize(name, manager, single, max)
100     @name = name.dup
101     @manager = manager
102     @single = single.to_i
103     @max = max.to_i
104     @pending = nil
105     @qas = []
106     @curr_idx = nil
107     @running = false
108     @scores = Hash.new
109
110     # the default is to make vowels usable only
111     # after paying a price in points which is
112     # a fraction of the single round score equal
113     # to the number of rounds needed to win the game
114     # TODO customize
115     @must_buy = %w{a e i o u y}
116     @price = @single*@single/@max
117   end
118
119   def running?
120     @running
121   end
122
123   def round
124     @curr_idx+1 rescue 0
125   end
126
127   def length
128     @qas.length
129   end
130
131   def qa(round)
132     if @pending and round == self.length + 1
133       @pending
134     else
135       @qas[round-1]
136     end
137   end
138
139   def buy(user)
140     k = user.botuser
141     if @scores.key?(k) and @scores[k][:score] >= @price
142       @scores[k][:score] -= @price
143       return true
144     else
145       return false
146     end
147   end
148
149   def score(user)
150     k = user.botuser
151     if @scores.key?(k)
152       @scores[k][:score]
153     else
154       0
155     end
156   end
157
158   def mark_guessed(qa)
159     qa.guessed = true
160   end
161
162   def mark_winner(user)
163     @running = false
164     k = user.botuser
165     if @scores.key?(k)
166       @scores[k][:nick] = user.nick
167       @scores[k][:score] += @single
168     else
169       @scores[k] = { :nick => user.nick, :score => @single }
170     end
171     if @scores[k][:score] >= @max
172       return :done
173     else
174       return :more
175     end
176   end
177
178   def score_table
179     table = []
180     @scores.each { |k, val|
181       table << ["%s (%s)" % [val[:nick], k], val[:score]]
182     }
183     table.sort! { |a, b| b.last <=> a.last }
184   end
185
186   def current
187     return nil unless @curr_idx
188     @qas[@curr_idx]
189   end
190
191   def next
192     # don't advance if there are no further QAs
193     return nil if @curr_idx == @qas.length - 1
194     if @curr_idx
195       @curr_idx += 1
196     else
197       @curr_idx = 0
198     end
199     return current
200   end
201
202   def check(whatever, o={})
203     cur = self.current
204     return nil unless cur
205     if @must_buy.include?(whatever) and not o[:buy]
206       return whatever
207     end
208     return cur.check(whatever)
209   end
210
211   def start_add_qa(cat, clue)
212     return [nil, @pending] if @pending
213     @pending = WoFQA.new(cat.dup, clue.dup)
214     return [true, @pending]
215   end
216
217   def finish_add_qa(ans)
218     return nil unless @pending
219     @pending.answer = ans.dup
220     @qas << @pending
221     @pending = nil
222     return @qas.last
223   end
224 end
225
226 class WheelOfFortune < Plugin
227   Config.register Config::StringValue.new('wheelfortune.game_name',
228     :default => 'Wheel Of Fortune',
229     :desc => "default name of the Wheel Of Fortune game")
230
231   def initialize
232     super
233     # TODO load/save running games?
234     @games = Hash.new
235   end
236
237   def help(plugin, topic="")
238     case topic
239     when 'play'
240       _("wof [<channel>] play [<name>] for <single> to <max> => starts a wheel-of-fortune game on channel <channel> (default: current channel), named <name> (default: wheelfortune.game_name config setting, or the last game name used by the user), with <single> points per round. the game is won when a player reachers <max> points. vowels cost <single>*<single>/<max> points. The user that starts the game is the game manager and must set up the clues and answers in private. All the other users have to learn the answer to each clue by saying single consonants or the whole sentence. Every time a consonant is guessed, the bot will reveal the partial answer, showing the missing letters as * (asterisks).")
241     when 'category', 'clue', 'answer'
242       _("wof <channel> [category: <cat>,] clue: <clue>, answer: <ans> => set up a new question for the wheel-of-fortune game being played on channel <channel>. This command must be sent in private by the game manager. The category <cat> can be omitted. If you make mistakes, you can use 'wof replace' (see help) before the question gets asked")
243     when 'replace'
244       _("wof <channel> replace <round> [category: <cat>,] [clue: <clue>,] [answer: <ans>] => fix the question for round <round> of the wheel-of-fortune game being played on <channel> by replacing the category and/or clue and/or answer")
245     when 'cancel'
246       _("wof cancel => cancels the wheel-of-fortune being played on the current channel")
247     when 'buy'
248       _("wof buy <vowel> => buy the vowel <vowel>: the user buying the vowel will lose points equal to the vowel price, and the corresponding vowel will be revealed in the answer (if present)")
249     else
250       _("wof: wheel-of-fortune plugin. topics: play, category, clue, answer, replace, cancel, buy")
251     end
252   end
253
254   def setup_game(m, p)
255     chan = p[:chan] || m.channel
256     if !chan
257       m.reply _("you must specify a channel")
258       return
259     end
260     ch = chan.irc_downcase(m.server.casemap).intern
261
262     if game = @games[ch]
263       m.reply _("there's already a %{name} game on %{chan}, managed by %{who}") % {
264         :name => game.name,
265         :chan => chan,
266         :who => game.manager
267       }
268       return
269     end
270     name = p[:name].to_s
271     if name.empty?
272       name = m.source.get_botdata("wheelfortune.game_name") || @bot.config['wheelfortune.game_name']
273     else
274       m.source.set_botdata("wheelfortune.game_name", name.dup)
275     end
276     @games[ch] = game = WoFGame.new(name, m.botuser, p[:single], p[:max])
277     @bot.say chan, _("%{who} just created a new %{name} game to %{max} points (%{single} per question, %{price} per vowel)") % {
278       :name => game.name,
279       :who => game.manager,
280       :max => game.max,
281       :single => game.single,
282       :price => game.price
283     }
284     @bot.say m.source, _("ok, the game has been created. now add clues and answers with \"wof %{chan} [category: <category>,] clue: <clue>, answer: <ans>\". if the clue and answer don't fit in one line, add the answer separately with \"wof %{chan} answer <answer>\"") % {
285       :chan => chan
286     }
287   end
288
289   def setup_qa(m, p)
290     ch = p[:chan].irc_downcase(m.server.casemap).intern
291     if !@games.key?(ch)
292       m.reply _("there's no %{name} game running on %{chan}") % {
293         :name => @bot.config['wheelfortune.game_name'],
294         :chan => p[:chan]
295       }
296       return
297     end
298     game = @games[ch]
299
300     if m.botuser != game.manager and !m.botuser.permit?('wheelfortune::manage::other::add')
301       m.reply _("you can't add questions to the %{name} game on %{chan}") % {
302         :name => game.name,
303         :chan => p[:chan]
304       }
305     end
306
307     cat = p[:cat].to_s
308     clue = p[:clue].to_s
309     ans = p[:ans].to_s
310     if ans.include?('*')
311       m.reply _("sorry, the answer cannot contain the '*' character")
312       return
313     end
314
315     if !clue.empty?
316       worked, qa = game.start_add_qa(cat, clue)
317       if worked
318         str = ans.empty? ?  _("ok, clue added for %{name} round %{count} on %{chan}: %{catclue}") : nil
319       else
320         str = _("there's already a pending clue for %{name} round %{count} on %{chan}: %{catclue}")
321       end
322       m.reply _(str) % {
323         :chan => p[:chan],
324         :catclue => qa.catclue,
325         :name => game.name,
326         :count => game.length+1
327       } if str
328       return unless worked and !ans.empty?
329     end
330     if !ans.empty?
331       qa = game.finish_add_qa(ans)
332       if qa
333         str = _("ok, QA added for %{name} round %{count} on %{chan}: %{catclue} => %{ans}")
334       else
335         str = _("there's no pending clue for %{name} on %{chan}!")
336       end
337       m.reply _(str) % {
338         :chan => p[:chan],
339         :catclue => qa ? qa.catclue : nil,
340         :ans => qa ? qa.answer : nil,
341         :name => game.name,
342         :count => game.length
343       }
344       announce(m, p) unless game.running?
345     else
346       m.reply _("something went wrong, I can't seem to understand what you're trying to set up") if clue.empty?
347     end
348   end
349
350   def replace_qa(m, p)
351     ch = p[:chan].irc_downcase(m.server.casemap).intern
352     if !@games.key?(ch)
353       m.reply _("there's no %{name} game running on %{chan}") % {
354         :name => @bot.config['wheelfortune.game_name'],
355         :chan => p[:chan]
356       }
357       return
358     end
359     game = @games[ch]
360
361     if m.botuser != game.manager and !m.botuser.permit?('wheelfortune::manage::other::add')
362       m.reply _("you can't replace questions to the %{name} game on %{chan}") % {
363         :name => game.name,
364         :chan => p[:chan]
365       }
366     end
367
368     round = p[:round].to_i
369
370     min = game.round
371     max = game.length
372     max += 1 if game.pending
373     if round <= min or round > max
374       if min == max
375         m.reply _("there are no questions in the %{name} game on %{chan} which can be replaced") % {
376           :name => game.name,
377           :chan => p[:chan]
378         }
379       else
380         m.reply _("you can only replace questions between rounds %{min} and %{max} in the %{name} game on %{chan}") % {
381           :name => game.name,
382           :min => min,
383           :max => max,
384           :chan => p[:chan]
385         }
386       end
387       return
388     end
389
390     cat = p[:cat].to_s
391     clue = p[:clue].to_s
392     ans = p[:ans].to_s
393     if ans.include?('*')
394       m.reply _("sorry, the answer cannot contain the '*' character")
395       return
396     end
397
398     qa = game.qa(round)
399     qa.cat = cat unless cat.empty?
400     qa.clue = clue unless clue.empty?
401     unless ans.empty?
402       if game.pending and round == max
403         game.finish_add_qa(ans)
404       else
405         qa.answer = ans
406       end
407     end
408
409     str = _("ok, replaced QA for %{name} round %{count} on %{chan}: %{catclue} => %{ans}")
410     m.reply str % {
411       :chan => p[:chan],
412       :catclue => qa ? qa.catclue : nil,
413       :ans => qa ? qa.answer : nil,
414       :name => game.name,
415       :count => round
416     }
417   end
418
419   def announce(m, p={})
420     chan = p[:chan] || m.channel
421     ch = chan.irc_downcase(m.server.casemap).intern
422     if !@games.key?(ch)
423       m.reply _("there's no %{name} game running on %{chan}") % {
424         :name => @bot.config['wheelfortune.game_name'],
425         :chan => chan
426       }
427       return
428     end
429     game = @games[ch]
430     qa = game.current
431     if !qa or qa.guessed?
432       qa = game.next
433     end
434     if !qa
435       m.reply _("there are no %{name} questions for %{chan}, I'm waiting for %{who} to add them") % {
436         :name => game.name,
437         :chan => chan,
438         :who => game.manager
439       }
440       return
441     end
442
443     @bot.say chan, _("%{bold}%{color}%{name}%{bold}, round %{count}:%{nocolor} %{qa}") % {
444       :bold => Bold,
445       :color => Irc.color(:green),
446       :name => game.name,
447       :count => game.round,
448       :nocolor => Irc.color(),
449       :qa => qa.announcement,
450     }
451     game.running = true
452   end
453
454   def score_table(chan, game, opts={})
455     limit = opts[:limit] || -1
456     table = game.score_table[0..limit]
457     if table.length == 0
458       @bot.say chan, _("no scores")
459       return
460     end
461     nick_wd = table.map { |a| a.first.length }.max
462     score_wd = table.first.last.to_s.length
463     table.each { |t|
464       @bot.say chan, "%*s : %*u" % [nick_wd, t.first, score_wd, t.last]
465     }
466   end
467
468   def react_on_check(m, ch, game, check)
469     debug "check: #{check.inspect}"
470     case check
471     when nil
472       # can this happen?
473       warning "game #{game}, qa #{game.current} checked nil against #{m.message}"
474       return
475     when :used
476       # m.reply "STUPID! YOU SO STUPID!"
477       return
478     when *game.must_buy
479       m.reply _("You must buy the %{vowel}") % {:vowel => check}, :nick => true
480     when :wrong
481       return
482     when Numeric, :missing
483       # TODO may alter score depening on how many letters were guessed
484       # TODO what happens when the last hint reveals the whole answer?
485       announce(m)
486     when :gotit
487       game.mark_guessed(game.current)
488       want_more = game.mark_winner(m.source)
489       m.reply _("%{who} got it! The answer was: %{ans}") % {
490         :who => m.sourcenick,
491         :ans => game.current.answer
492       }
493       if want_more == :done
494         # max score reached
495         m.reply _("%{bold}%{color}%{name}%{bold}%{nocolor}: %{who} %{bold}wins%{bold} after %{count} rounds!\nThe final score is") % {
496           :bold => Bold,
497           :color => Irc.color(:green),
498           :who => m.sourcenick,
499           :name => game.name,
500           :count => game.round,
501           :nocolor => Irc.color()
502         }
503         score_table(m.channel, game)
504         @games.delete(ch)
505       else
506         m.reply _("%{bold}%{color}%{name}%{bold}, round %{count}%{nocolor} -- score so far:") % {
507           :bold => Bold,
508           :color => Irc.color(:green),
509           :name => game.name,
510           :count => game.round,
511           :nocolor => Irc.color()
512         }
513         score_table(m.channel, game)
514         announce(m)
515       end
516     else
517       # can this happen?
518       warning "game #{game}, qa #{game.current} checked #{check} against #{m.message}"
519     end
520   end
521
522   def message(m)
523     return if m.address?
524     ch = m.channel.irc_downcase(m.server.casemap).intern
525     return unless game = @games[ch]
526     return unless game.running?
527     return unless game.current and not game.current.guessed?
528     check = game.check(m.message, :buy => false)
529     react_on_check(m, ch, game, check)
530   end
531
532   def buy(m, p)
533     ch = m.channel.irc_downcase(m.server.casemap).intern
534     game = @games[ch]
535     if not game
536       m.reply _("there's no %{name} game running on %{chan}") % {
537         :name => @bot.config['wheelfortune.game_name'],
538         :chan => m.channel
539       }
540       return
541     elsif !game.running?
542       m.reply _("there are no %{name} questions for %{chan}, I'm waiting for %{who} to add them") % {
543         :name => game.name,
544         :chan => chan,
545         :who => game.manager
546       }
547       return
548     else
549       vowel = p[:vowel]
550       bought = game.buy(m.source)
551       if bought
552         m.reply _("%{who} buys a %{vowel} for %{price} points") % {
553           :who => m.source,
554           :vowel => vowel,
555           :price => game.price
556         }
557         check = game.check(vowel, :buy => true)
558         react_on_check(m, ch, game, check)
559       else
560         m.reply _("you can't buy a %{vowel}, %{who}: it costs %{price} points and you only have %{score}") % {
561           :who => m.source,
562           :vowel => vowel,
563           :price => game.price,
564           :score => game.score(m.source)
565         }
566       end
567     end
568   end
569
570   def cancel(m, p)
571     ch = m.channel.irc_downcase(m.server.casemap).intern
572     if !@games.key?(ch)
573       m.reply _("there's no %{name} game running on %{chan}") % {
574         :name => @bot.config['wheelfortune.game_name'],
575         :chan => m.channel
576       }
577       return
578     end
579     # is the botuser the manager or allowed to cancel someone else's game?
580     if m.botuser == @games[ch].manager or m.botuser.permit?('wheelfortune::manage::other::cancel')
581       do_cancel(ch)
582     else
583       m.reply _("you can't cancel the current game")
584     end
585   end
586
587   def do_cancel(ch)
588     game = @games.delete(ch)
589     chan = ch.to_s
590     @bot.say chan, _("%{name} game cancelled after %{count} rounds. Partial score:") % {
591       :name => game.name,
592       :count => game.round
593     }
594     score_table(chan, game)
595   end
596
597   def cleanup
598     @games.each_key { |k| do_cancel(k) }
599     super
600   end
601 end
602
603 plugin = WheelOfFortune.new
604
605 plugin.map "wof", :action => 'announce', :private => false
606 plugin.map "wof cancel", :action => 'cancel', :private => false
607 plugin.map "wof [:chan] play [*name] for :single [points] to :max [points]", :action => 'setup_game'
608 plugin.map "wof :chan [category: *cat,] clue: *clue[, answer: *ans]", :action => 'setup_qa', :public => false
609 plugin.map "wof :chan answer: *ans", :action => 'setup_qa', :public => false
610 plugin.map "wof :chan replace :round [category: *cat,] clue: *clue[, answer: *ans]", :action => 'replace_qa', :public => false
611 plugin.map "wof :chan replace :round [category: *cat,] answer: *ans", :action => 'replace_qa', :public => false
612 plugin.map "wof :chan replace :round category: *cat[, clue: *clue[, answer: *ans]]", :action => 'replace_qa', :public => false
613 plugin.map "wof buy :vowel", :action => 'buy', :requirements => { :vowel => /./u }