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