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