uno plugin: downcase parameters
[rbot] / data / rbot / plugins / games / uno.rb
1 #-- vim:sw=2:et
2 #++
3 #
4 # :title: Uno Game Plugin for rbot
5 #
6 # Author:: Giuseppe "Oblomov" Bilotta <giuseppe.bilotta@gmail.com>
7 #
8 # Copyright:: (C) 2008 Giuseppe Bilotta
9 #
10 # License:: GPL v2
11 #
12 # Uno Game: get rid of the cards you have
13
14 class UnoGame
15   COLORS = %w{Red Green Blue Yellow}
16   SPECIALS = %w{+2 Reverse Skip}
17   NUMERICS = (0..9).to_a
18   VALUES = NUMERICS + SPECIALS
19
20   def UnoGame.color_map(clr)
21     case clr
22     when 'Red'
23       :red
24     when 'Blue'
25       :royal_blue
26     when 'Green'
27       :limegreen
28     when 'Yellow'
29       :yellow
30     end
31   end
32
33   def UnoGame.irc_color_bg(clr)
34     Irc.color([:white,:black][COLORS.index(clr)%2],UnoGame.color_map(clr))
35   end
36
37   def UnoGame.irc_color_fg(clr)
38     Irc.color(UnoGame.color_map(clr))
39   end
40
41   def UnoGame.colorify(str, fg=false)
42     ret = Bold.dup
43     str.length.times do |i|
44       ret << (fg ?
45               UnoGame.irc_color_fg(COLORS[i%4]) :
46               UnoGame.irc_color_bg(COLORS[i%4]) ) +str[i,1]
47     end
48     ret << NormalText
49   end
50
51   UNO = UnoGame.colorify('UNO!', true)
52
53   # Colored play cards
54   class Card
55     attr_reader :color
56     attr_reader :value
57     attr_reader :shortform
58     attr_reader :to_s
59     attr_reader :score
60
61     def initialize(color, value)
62       raise unless COLORS.include? color
63       @color = color.dup
64       raise unless VALUES.include? value
65       if NUMERICS.include? value
66         @value = value
67         @score = value
68       else
69         @value = value.dup
70         @score = 20
71       end
72       if @value == '+2'
73         @shortform = (@color[0,1]+@value).downcase
74       else
75         @shortform = (@color[0,1]+@value.to_s[0,1]).downcase
76       end
77       @to_s = UnoGame.irc_color_bg(@color) +
78         Bold + ['', @color, @value, ''].join(' ') + NormalText
79     end
80
81     def picker
82       return 0 unless @value.to_s[0,1] == '+'
83       return @value[1,1].to_i
84     end
85
86     def special?
87       SPECIALS.include?(@value)
88     end
89   end
90
91   # Wild, Wild +4 cards
92   class Wild < Card
93     def initialize(value=nil)
94       @color = 'Wild'
95       raise if value and not value == '+4'
96       if value
97         @value = value.dup 
98         @shortform = 'w'+value
99       else
100         @value = nil
101         @shortform = 'w'
102       end
103       @score = 50
104       @to_s = UnoGame.colorify(['', @color, @value, ''].compact.join(' '))
105     end
106     def special?
107       @value
108     end
109   end
110
111   class Player
112     attr_accessor :cards
113     attr_reader :user
114     def initialize(user)
115       @user = user
116       @cards = []
117     end
118     def has_card?(short)
119       cards = []
120       @cards.each { |c|
121         cards << c if c.shortform == short
122       }
123       if cards.empty?
124         return false
125       else
126         return cards
127       end
128     end
129     def to_s
130       Bold + @user.to_s + Bold
131     end
132   end
133
134   attr_reader :stock
135   attr_reader :discard
136   attr_reader :channel
137   attr :players
138   attr_reader :player_has_picked
139   attr_reader :picker
140
141   def initialize(plugin, channel)
142     @channel = channel
143     @plugin = plugin
144     @bot = plugin.bot
145     @players = []
146     @discard = nil
147     make_base_stock
148     @stock = []
149     make_stock
150     @start_time = nil
151     @join_timer = nil
152   end
153
154   def get_player(user)
155     @players.each { |p| return p if p.user == user }
156     return nil
157   end
158
159   def announce(msg, opts={})
160     @bot.say channel, msg, opts
161   end
162
163   def notify(player, msg, opts={})
164     @bot.notice player.user, msg, opts
165   end
166
167   def make_base_stock
168     @base_stock = COLORS.inject([]) do |list, clr|
169       VALUES.each do |n|
170         list << Card.new(clr, n)
171         list << Card.new(clr, n) unless n == 0
172       end
173       list
174     end
175     4.times do
176       @base_stock << Wild.new
177       @base_stock << Wild.new('+4')
178     end
179   end
180
181   def make_stock
182     @stock.replace @base_stock
183     # remove the cards in the players hand
184     @players.each { |p| p.cards.each { |c| @stock.delete_one c } }
185     # remove current top discarded card if present
186     if @discard
187       @stock.delete_one(discard)
188     end
189     @stock.shuffle!
190   end
191
192   def start_game
193     debug "Starting game"
194     @players.shuffle!
195     show_order
196     announce _("%{p} deals the first card from the stock") % {
197       :p => @players.first
198     }
199     card = @stock.shift
200     @picker = 0
201     @special = false
202     while Wild === card do
203       @stock.insert(rand(@stock.length), card)
204       card = @stock.shift
205     end
206     set_discard(card)
207     show_discard
208     if @special
209       do_special
210     end
211     next_turn
212     @start_time = Time.now
213   end
214
215   def reverse_turn
216     if @players.length > 2
217       @players.reverse!
218       # put the current player back in its place
219       @players.unshift @players.pop
220       announce _("Playing order was reversed!")
221     else
222       skip_turn
223     end
224   end
225
226   def skip_turn
227     @players << @players.shift
228     announce _("%{p} skips a turn!") % {
229       # this is first and not last because the actual
230       # turn change will be done by the following next_turn
231       :p => @players.first
232     }
233   end
234
235   def do_special
236     case @discard.value
237     when 'Reverse'
238       reverse_turn
239       @special = false
240     when 'Skip'
241       skip_turn
242       @special = false
243     end
244   end
245
246   def set_discard(card)
247     @discard = card
248     @value = card.value.dup rescue card.value
249     if Wild === card
250       @color = nil
251     else
252       @color = card.color.dup
253     end
254     if card.picker > 0
255       @picker += card.picker
256     end
257     if card.special?
258       @special = true
259     else
260       @special = false
261     end
262   end
263
264   def next_turn(opts={})
265     @players << @players.shift
266     @player_has_picked = false
267     show_turn
268   end
269
270   def can_play(card)
271     # When a +something is online, you can only play
272     # a +something of same or higher something, or a Reverse
273     # TODO make optional
274     if @picker > 0
275       if card.color and card.color != @color
276         return false
277       end
278       if card.value == 'Reverse' or card.picker >= @discard.picker
279         return true
280       else
281         return false
282       end
283     else
284       # You can always play a Wild
285       # FIXME W+4 can only be played if you don't have a proper card
286       # TODO make it playable anyway, and allow players to challenge
287       return true if Wild === card
288       # On a Wild, you must match the color
289       if Wild === @discard
290         return card.color == @color
291       else
292         # Otherwise, you can match either the value or the color
293         return (card.value == @value) || (card.color == @color)
294       end
295     end
296   end
297
298   def play_card(source, cards)
299     debug "Playing card #{cards}"
300     p = get_player(source)
301     shorts = cards.scan(/[rbgy]\s*(?:\+?\d|[rs])|w\s*(?:\+4)?/)
302     debug shorts.inspect
303     if shorts.length > 2 or shorts.length < 1
304       announce _("you can only play one or two cards")
305       return
306     end
307     if shorts.length == 2 and shorts.first != shorts.last
308       announce _("you can only play two cards if they are the same")
309       return
310     end
311     if cards = p.has_card?(shorts.first)
312       debug cards
313       unless can_play(cards.first)
314         announce _("you can't play that card")
315         return
316       end
317       if cards.length >= shorts.length
318         set_discard(p.cards.delete_one(cards.shift))
319         if shorts.length > 1
320           set_discard(p.cards.delete_one(cards.shift))
321           announce _("%{p} plays %{card} twice!") % {
322             :p => source,
323             :card => @discard
324           }
325         else
326           announce _("%{p} plays %{card}") % { :p => source, :card => @discard }
327         end
328         if p.cards.length == 1
329           announce _("%{p} has %{uno}!") % {
330             :p => source, :uno => UNO
331           }
332         elsif p.cards.length == 0
333           end_game
334           return
335         end
336         show_picker
337         if @color
338           if @special
339             do_special
340           end
341           next_turn
342         else
343           announce _("%{p}, choose a color with: co r|b|g|y") % { :p => p }
344         end
345       else
346         announce _("you don't have that card")
347       end
348     end
349   end
350
351   def pass(user)
352     p = get_player(user)
353     if @picker > 0
354       announce _("%{p} passes turn, and has to pick %{b}%{n}%{b} cards!") % {
355         :p => user, :b => Bold, :n => @picker
356       }
357       deal(p, @picker)
358       @picker = 0
359     else
360       if @player_has_picked
361         announce _("%{p} passes turn") % { :p => user }
362       else
363         announce _("you need to pick a card first")
364         return
365       end
366     end
367     next_turn
368   end
369
370   def choose_color(user, color)
371     case color
372     when 'r'
373       @color = 'Red'
374     when 'b'
375       @color = 'Blue'
376     when 'g'
377       @color = 'Green'
378     when 'y'
379       @color = 'Yellow'
380     else
381       announce _('what color is that?')
382       return
383     end
384     announce _('color is now %{c}') % { :c => @color.downcase }
385     next_turn
386   end
387
388   def show_time
389     if @start_time
390       announce _("This %{uno} game has been going on for %{time}") % {
391         :uno => UNO,
392         :time => Utils.secs_to_string(Time.now - @start_time)
393       }
394     else
395       announce _("The game hasn't started yet")
396     end
397   end
398
399   def show_order
400     announce _("%{uno} playing turn: %{players}") % {
401       :uno => UNO, :players => players.join(' ')
402     }
403   end
404
405   def show_turn(opts={})
406     cards = true
407     cards = opts[:cards] if opts.key?(:cards)
408     player = @players.first
409     announce _("it's %{player}'s turn") % { :player => player }
410     show_user_cards(player) if cards
411   end
412
413   def has_turn?(source)
414     @players.first.user == source
415   end
416
417   def show_picker
418     if @picker > 0
419       announce _("next player must respond correctly or pick %{b}%{n}%{b} cards") % {
420         :b => Bold, :n => @picker
421       }
422     end
423   end
424
425   def show_discard
426     announce _("Current discard: %{card} %{c}") % { :card => @discard,
427       :c => (Wild === @discard) ? UnoGame.irc_color_bg(@color) + " #{@color} " : nil
428     }
429     show_picker
430   end
431
432   def show_user_cards(player)
433     p = Player === player ? player : get_player(player)
434     notify p, _('Your cards: %{cards}') % {
435       :cards => p.cards.join(' ')
436     }
437   end
438
439   def show_all_cards(u=nil)
440     announce(@players.inject([]) { |list, p|
441       list << [p, p.cards.length].join(': ')
442     }.join(', '))
443     if u
444       show_user_cards(u)
445     end
446   end
447
448   def pick_card(user)
449     p = get_player(user)
450     announce _("%{player} picks a card") % { :player => p }
451     deal(p, 1)
452     @player_has_picked = true
453   end
454
455   def deal(player, num=1)
456     picked = []
457     num.times do
458       picked << @stock.delete_one
459       if @stock.length == 0
460         announce _("Shuffling discarded cards")
461         make_stock
462         if @stock.length == 0
463           announce _("No more cards!")
464           end_game # FIXME nope!
465         end
466       end
467     end
468     notify player, _("You picked %{picked}") % { :picked => picked.join(' ') }
469     player.cards += picked
470   end
471
472   def add_player(user)
473     return if get_player(user)
474     p = Player.new(user)
475     @players << p
476     deal(p, 7)
477     if @join_timer
478       @bot.timer.reschedule(@join_timer, 10)
479     elsif @players.length > 1
480       announce _("game will start in 20 seconds")
481       @join_timer = @bot.timer.add_once(20) {
482         start_game
483       }
484     end
485   end
486
487   def end_game
488     announce _("%{uno} game finished! The winner is %{p}") % {
489       :uno => UNO, :p => @players.first
490     }
491     if @picker > 0
492       p = @player[1]
493       announce _("%{p} has to pick %{b}%{n}%{b} cards!") % {
494         :p => p, :n => @picker, :b => Bold
495       }
496       deal(@player[1], @picker)
497       @picker = 0
498     end
499     score = @players.inject(0) do |sum, p|
500       if p.cards.length > 0
501         announce _("%{p} still had %{cards}") % {
502           :p => p, :cards => p.cards.join(' ')
503         }
504         sum += p.cards.inject(0) do |cs, c|
505           cs += c.score
506         end
507       end
508       sum
509     end
510     announce _("%{p} wins with %{b}%{score}%{b} points!") % {
511         :p => @players.first, :score => score, :b => Bold
512     }
513     @plugin.end_game(@channel)
514   end
515
516 end
517
518 class UnoPlugin < Plugin
519   attr :games
520   def initialize
521     super
522     @games = {}
523   end
524
525   def help(plugin, topic="")
526     (_("%{uno} game. !uno to start a game. in-game commands (no prefix): ") % {
527       :uno => UnoGame::UNO
528     }) + [
529       _("'jo' to join in"),
530       _("'pl <card>' to play <card>"),
531       _("'pe' to pick a card"),
532       _("'pa' to pass your turn"),
533       _("'co <color>' to pick a color"),
534       _("'ca' to show current cards"),
535       _("'cd' to show the current discard"),
536       _("'od' to show the playing order"),
537       _("'ti' to show play time"),
538       _("'tu' to show whose turn it is")
539     ].join(" ; ")
540   end
541
542   def message(m)
543     return unless @games.key?(m.channel)
544     g = @games[m.channel]
545     case m.plugin.intern
546     when :jo # join game
547       g.add_player(m.source)
548     when :pe # pick card
549       if g.has_turn?(m.source)
550         if g.player_has_picked
551           m.reply _("you already picked a card")
552         elsif g.picker > 0
553           m.reply _("you can't pick a card")
554         else
555           g.pick_card(m.source)
556         end
557       else
558         m.reply _("It's not your turn")
559       end
560     when :pa # pass turn
561       if g.has_turn?(m.source)
562         g.pass(m.source)
563       else
564         m.reply _("It's not your turn")
565       end
566     when :pl # play card
567       if g.has_turn?(m.source)
568         g.play_card(m.source, m.params.downcase)
569       else
570         m.reply _("It's not your turn")
571       end
572     when :co # pick color
573       if g.has_turn?(m.source)
574         g.choose_color(m.source, m.params.downcase)
575       else
576         m.reply _("It's not your turn")
577       end
578     when :ca # show current cards
579       g.show_all_cards(m.source)
580     when :cd # show current discard
581       g.show_discard
582     # TODO
583     # when :ch
584     #   g.challenge
585     when :od # show playing order
586       g.show_order
587     when :ti # show play time
588       g.show_time
589     when :tu # show whose turn is it
590       if g.has_turn?(m.source)
591         m.nickreply _("it's your turn, sleepyhead")
592       else
593         g.show_turn(:cards => false)
594       end
595     end
596   end
597
598   def create_game(m, p)
599     if @games.key?(m.channel)
600       m.reply _("There is already an %{uno} game running here, say 'jo' to join in") % { :uno => UnoGame::UNO }
601       return
602     end
603     @games[m.channel] = UnoGame.new(self, m.channel)
604     m.reply _("Ok, created %{uno} game on %{channel}, say 'jo' to join in") % {
605       :uno => UnoGame::UNO,
606       :channel => m.channel
607     }
608   end
609
610   def end_game(channel)
611     @games.delete(channel)
612   end
613
614   def print_stock(m, p)
615     unless @games.key?(m.channel)
616       m.reply _("There is no %{uno} game running here") % { :uno => UnoGame::UNO }
617       return
618     end
619     stock = @games[m.channel].stock
620     m.reply(_("%{num} cards in stock: %{stock}") % {
621       :num => stock.length,
622       :stock => stock.join(' ')
623     }, :split_at => /#{NormalText}\s*/)
624   end
625 end
626
627 pg = UnoPlugin.new
628
629 pg.map 'uno', :private => false, :action => :create_game
630 pg.map 'uno stock', :private => false, :action => :print_stock
631 pg.default_auth('stock', false)