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