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