wheelfortune: Wheel-of-Fortune game plugin. Initial commit
[rbot] / 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 << "\n"
46     ret << _("Letters called so far: ") << @used.join(" ") << "\n" unless @used.empty?
47     ret << @hint.join
48   end
49
50   def check(ans_or_letter)
51     d = ans_or_letter.downcase
52     if d == @answer
53       return :gotit
54     elsif d.length == 1
55       if @used.include?(d)
56         return :used
57       else
58         @used << d
59         @used.sort!
60         if @split.include?(d)
61           count = 0
62           @split.each_with_index { |c, i|
63             if c == d
64               @hint[i] = d.upcase
65               count += 1
66             end
67           }
68           return count
69         else
70           return :missing
71         end
72       end
73     else
74       return :wrong
75     end
76   end
77
78 end
79
80 # Wheel-of-Fortune game
81 class WoFGame
82   attr_reader :manager, :single, :max, :pending
83   def initialize(manager, single, max)
84     @manager = manager
85     @single = single.to_i
86     @max = max.to_i
87     @pending = nil
88     @qas = []
89     @curr_idx = nil
90     @last_replied = nil
91     @scores = Hash.new
92   end
93
94   def waiting?
95     !@curr_idx || (@last_replied == @curr_idx)
96   end
97
98   def round
99     @curr_idx+1
100   end
101
102   def mark_winner(user)
103     @last_replied = @curr_idx
104     k = user.botuser
105     if @scores.key?(k)
106       @scores[k][:nick] = user.nick
107       @scores[k][:score] += @single
108     else
109       @scores[k] = { :nick => user.nick, :score => @single }
110     end
111     if @scores[k][:score] >= @max
112       return :done
113     else
114       return :more
115     end
116   end
117
118   def score_table
119     table = []
120     @scores.each { |k, val|
121       table << ["%s (%s)" % [val[:nick], k], val[:score]]
122     }
123     table.sort! { |a, b| b.last <=> a.last }
124   end
125
126   def current
127     return nil unless @curr_idx
128     @qas[@curr_idx]
129   end
130
131   def next
132     # don't advance if there are no further QAs
133     return nil if @curr_idx == @qas.length - 1
134     if @curr_idx
135       @curr_idx += 1
136     else
137       @curr_idx = 0
138     end
139     return current
140   end
141
142   def check(whatever)
143     cur = self.current
144     return nil unless cur
145     return cur.check(whatever)
146   end
147
148   def start_add_qa(cat, clue)
149     return [nil, @pending] if @pending
150     @pending = WoFQA.new(cat.dup, clue.dup)
151     return [true, @pending]
152   end
153
154   def finish_add_qa(ans)
155     return nil unless @pending
156     @pending.answer = ans.dup
157     @qas << @pending
158     @pending = nil
159     return @qas.last
160   end
161 end
162
163 class WheelOfFortune < Plugin
164   def initialize
165     super
166     # TODO load/save running games?
167     @games = Hash.new
168   end
169
170   def setup_game(m, p)
171     chan = p[:chan] || m.channel
172     if !chan
173       m.reply _("you must specify a channel")
174       return
175     end
176     ch = p[:chan].irc_downcase(m.server.casemap).intern
177
178     if @games.key?(ch)
179       m.reply _("there's already a Wheel-of-Fortune game on %{chan}, managed by %{who}") % {
180         :chan => chan,
181         :who => @games[ch].manager
182       }
183       return
184     end
185     @games[ch] = game = WoFGame.new(m.botuser, p[:single], p[:max])
186     @bot.say chan, _("%{who} just created a new Wheel-of-Fortune game to %{max} points (%{single} per question)") % {
187       :who => game.manager,
188       :max => game.max,
189       :single => game.single
190     }
191     @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>\"") % {
192       :chan => chan
193     }
194   end
195
196   def setup_qa(m, p)
197     ch = p[:chan].irc_downcase(m.server.casemap).intern
198     if !@games.key?(ch)
199       m.reply _("there's no Wheel-of-Fortune game running on %{chan}") % {
200         :chan => p[:chan]
201       }
202       return
203     end
204     game = @games[ch]
205     cat = p[:cat].to_s
206     clue = p[:clue].to_s
207     ans = p[:ans].to_s
208     if ans.include?('*')
209       m.reply _("sorry, the answer cannot contain the '*' character")
210       return
211     end
212
213     if !clue.empty?
214       worked, qa = game.start_add_qa(cat, clue)
215       if worked
216         str = ans.empty? ?  _("ok, new clue added for %{chan}: %{catclue}") : nil
217       else
218         str = _("there's already a pending clue for %{chan}: %{catclue}")
219       end
220       m.reply _(str) % { :chan => p[:chan], :catclue => qa.catclue } if str
221       return unless worked or !ans.empty?
222     end
223     if !ans.empty?
224       qa = game.finish_add_qa(ans)
225       if qa
226         str = _("ok, new QA added for %{chan}: %{catclue} => %{ans}")
227       else
228         str = _("there's no pending clue for %{chan}!")
229       end
230       m.reply _(str) % { :chan => p[:chan], :catclue => qa ? qa.catclue : nil, :ans => qa ? qa.answer : nil}
231       announce(m, p.merge({ :next => true }) ) if game.waiting?
232     else
233       m.reply _("something went wrong, I can't seem to understand what you're trying to set up")
234     end
235   end
236
237   def announce(m, p={})
238     chan = p[:chan] || m.channel
239     ch = chan.irc_downcase(m.server.casemap).intern
240     if !@games.key?(ch)
241       m.reply _("there's no Wheel-of-Fortune game running on %{chan}") % { :chan => p[:chan] }
242       return
243     end
244     game = @games[ch]
245     qa = p[:next] ? game.next : game.current
246     if !qa
247       m.reply _("there are no Wheel-of-Fortune questions for %{chan}, I'm waiting for %{who} to add them") % {
248         :chan => chan,
249         :who => game.manager
250       }
251       return
252     end
253
254     @bot.say chan, qa.announcement
255   end
256
257   def score_table(chan, game, opts={})
258     limit = opts[:limit] || -1
259     table = game.score_table[0..limit]
260     nick_wd = table.map { |a| a.first.length }.max
261     score_wd = table.first.to_s.length
262     table.each { |t|
263       @bot.say chan, "%*s : %*u" % [nick_wd, t.first, score_wd, t.last]
264     }
265   end
266
267   def listen(m)
268     return unless m.kind_of?(PrivMessage) and not m.address?
269     ch = m.channel.irc_downcase(m.server.casemap).intern
270     return unless game = @games[ch]
271     return if game.waiting?
272     check = game.check(m.message)
273     debug "check: #{check.inspect}"
274     case check
275     when nil
276       # can this happen?
277       warning "game #{game}, qa #{game.current} checked nil against #{m.message}"
278       return
279     when :used
280       # m.reply "STUPID! YOU SO STUPID!"
281       return
282     when :wrong
283       return
284     when Numeric, :missing
285       # TODO may alter score depening on how many letters were guessed
286       # TODO what happens when the last hint reveals the whole answer?
287       announce(m)
288     when :gotit
289       want_more = game.mark_winner(m.source)
290       m.reply _("%{who} got it! The answer was: %{ans}") % {
291         :who => m.sourcenick,
292         :ans => game.current.answer
293       }
294       if want_more == :done
295         # max score reached
296
297         m.reply _("%{who} wins the game after %{count} rounds!") % {
298           :who => table.first.first,
299           :count => game.round
300         }
301         score_table(m.channel, game)
302         @games.delete(ch)
303       else :more
304         table = game.score_table
305         nick_wd = table.map { |a| a.first.length }.max
306         score_wd = table.first.to_s.length
307         m.reply _("Score after %{count} rounds") % { :count => game.round }
308         score_table(m.channel, game)
309         announce(m, :next => true)
310       end
311     else
312       # can this happen?
313       warning "game #{game}, qa #{game.current} checked #{check} against #{m.message}"
314     end
315   end
316
317   def cancel(m, p)
318     ch = m.channel.irc_downcase(m.server.casemap).intern
319     if !@games.key?(ch)
320       m.reply _("there's no Wheel-of-Fortune game running on %{chan}") % {
321         :chan => m.channel
322       }
323       return
324     end
325     do_cancel(ch)
326   end
327
328   def do_cancel(ch)
329     game = @games.delete(ch)
330     chan = ch.to_s
331     @bot.say chan, _("Wheel-of-Fortune game cancelled after %{count} rounds. Partial score:")
332     score_table(chan, game)
333   end
334
335   def cleanup
336     @games.each_key { |k| do_cancel(k) }
337     super
338   end
339 end
340
341 plugin = WheelOfFortune.new
342
343 plugin.map "wof", :action => 'announce', :private => false
344 plugin.map "wof cancel", :action => 'cancel', :private => false
345 plugin.map "wof [:chan] play for :single [points] to :max [points]", :action => 'setup_game'
346 plugin.map "wof :chan [category: *cat,] clue: *clue[, answer: *ans]", :action => 'setup_qa', :public => false
347 plugin.map "wof :chan answer: *ans", :action => 'setup_qa', :public => false