]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - data/rbot/plugins/games/uno.rb
c22dc8a1eed378c872bcf1819fba772cfa46bb50
[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_accessor :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     @dropouts = []
163     @discard = nil
164     make_base_stock
165     @stock = []
166     make_stock
167     @start_time = nil
168     @join_timer = nil
169   end
170
171   def get_player(user)
172     @players.each { |p| return p if p.user == user }
173     return nil
174   end
175
176   def announce(msg, opts={})
177     @bot.say channel, msg, opts
178   end
179
180   def notify(player, msg, opts={})
181     @bot.notice player.user, msg, opts
182   end
183
184   def make_base_stock
185     @base_stock = COLORS.inject([]) do |list, clr|
186       VALUES.each do |n|
187         list << Card.new(clr, n)
188         list << Card.new(clr, n) unless n == 0
189       end
190       list
191     end
192     4.times do
193       @base_stock << Wild.new
194       @base_stock << Wild.new('+4')
195     end
196   end
197
198   def make_stock
199     @stock.replace @base_stock
200     # remove the cards in the players hand
201     @players.each { |p| p.cards.each { |c| @stock.delete_one c } }
202     # remove current top discarded card if present
203     if @discard
204       @stock.delete_one(discard)
205     end
206     @stock.shuffle!
207   end
208
209   def start_game
210     debug "Starting game"
211     @players.shuffle!
212     show_order
213     announce _("%{p} deals the first card from the stock") % {
214       :p => @players.first
215     }
216     card = @stock.shift
217     @picker = 0
218     @special = false
219     while Wild === card do
220       @stock.insert(rand(@stock.length), card)
221       card = @stock.shift
222     end
223     set_discard(card)
224     show_discard
225     if @special
226       do_special
227     end
228     next_turn
229     @start_time = Time.now
230   end
231
232   def reverse_turn
233     if @players.length > 2
234       @players.reverse!
235       # put the current player back in its place
236       @players.unshift @players.pop
237       announce _("Playing order was reversed!")
238     else
239       skip_turn
240     end
241   end
242
243   def skip_turn
244     @players << @players.shift
245     announce _("%{p} skips a turn!") % {
246       # this is first and not last because the actual
247       # turn change will be done by the following next_turn
248       :p => @players.first
249     }
250   end
251
252   def do_special
253     case @discard.value
254     when 'Reverse'
255       reverse_turn
256       @special = false
257     when 'Skip'
258       skip_turn
259       @special = false
260     end
261   end
262
263   def set_discard(card)
264     @discard = card
265     @value = card.value.dup rescue card.value
266     if Wild === card
267       @color = nil
268     else
269       @color = card.color.dup
270     end
271     if card.picker > 0
272       @picker += card.picker
273       @last_picker = @discard.picker
274     end
275     if card.special?
276       @special = true
277     else
278       @special = false
279     end
280   end
281
282   def next_turn(opts={})
283     @players << @players.shift
284     @player_has_picked = false
285     show_turn
286   end
287
288   def can_play(card)
289     # When a +something is online, you can only play
290     # a +something of same or higher something, or a Reverse of
291     # the correct color
292     # TODO make optional
293     if @picker > 0
294       if (card.value == 'Reverse' and card.color == @color) or card.picker >= @last_picker
295         return true
296       else
297         return false
298       end
299     else
300       # You can always play a Wild
301       # FIXME W+4 can only be played if you don't have a proper card
302       # TODO make it playable anyway, and allow players to challenge
303       return true if Wild === card
304       # On a Wild, you must match the color
305       if Wild === @discard
306         return card.color == @color
307       else
308         # Otherwise, you can match either the value or the color
309         return (card.value == @value) || (card.color == @color)
310       end
311     end
312   end
313
314   def play_card(source, cards)
315     debug "Playing card #{cards}"
316     p = get_player(source)
317     shorts = cards.gsub(/\s+/,'').match(/^(?:([rbgy]\d){1,2}|([rbgy](?:\+\d|[rs]))|(w(?:\+4)?)([rbgy])?)$/).to_a
318     debug shorts.inspect
319     if shorts.empty?
320       announce _("what cards were that again?")
321       return
322     end
323     full = shorts[0]
324     short = shorts[1] || shorts[2] || shorts[3]
325     jolly = shorts[3]
326     jcolor = shorts[4]
327     if jolly
328       toplay = 1
329     else
330       toplay = (full == short) ? 1 : 2
331     end
332     debug [full, short, jolly, jcolor, toplay].inspect
333     # r7r7 -> r7r7, r7, nil, nil
334     # r7 -> r7, r7, nil, nil
335     # w -> w, nil, w, nil
336     # wg -> wg, nil, w, g
337     if cards = p.has_card?(short)
338       debug cards
339       unless can_play(cards.first)
340         announce _("you can't play that card")
341         return
342       end
343       if cards.length >= toplay
344         set_discard(p.cards.delete_one(cards.shift))
345         if toplay > 1
346           set_discard(p.cards.delete_one(cards.shift))
347           announce _("%{p} plays %{card} twice!") % {
348             :p => p,
349             :card => @discard
350           }
351         else
352           announce _("%{p} plays %{card}") % { :p => p, :card => @discard }
353         end
354         if p.cards.length == 1
355           announce _("%{p} has %{uno}!") % {
356             :p => p, :uno => UNO
357           }
358         elsif p.cards.length == 0
359           end_game
360           return
361         end
362         show_picker
363         if @color
364           if @special
365             do_special
366           end
367           next_turn
368         elsif jcolor
369           choose_color(p.user, jcolor)
370         else
371           announce _("%{p}, choose a color with: co r|b|g|y") % { :p => p }
372         end
373       else
374         announce _("you don't have two cards of that kind")
375       end
376     else
377       announce _("you don't have that card")
378     end
379   end
380
381   def pass(user)
382     p = get_player(user)
383     if @picker > 0
384       announce _("%{p} passes turn, and has to pick %{b}%{n}%{b} cards!") % {
385         :p => p, :b => Bold, :n => @picker
386       }
387       deal(p, @picker)
388       @picker = 0
389     else
390       if @player_has_picked
391         announce _("%{p} passes turn") % { :p => p }
392       else
393         announce _("you need to pick a card first")
394         return
395       end
396     end
397     next_turn
398   end
399
400   def choose_color(user, color)
401     case color
402     when 'r'
403       @color = 'Red'
404     when 'b'
405       @color = 'Blue'
406     when 'g'
407       @color = 'Green'
408     when 'y'
409       @color = 'Yellow'
410     else
411       announce _('what color is that?')
412       return
413     end
414     announce _('color is now %{c}') % {
415       :c => UnoGame.irc_color_bg(@color)+" #{@color} "
416     }
417     next_turn
418   end
419
420   def show_time
421     if @start_time
422       announce _("This %{uno} game has been going on for %{time}") % {
423         :uno => UNO,
424         :time => Utils.secs_to_string(Time.now - @start_time)
425       }
426     else
427       announce _("The game hasn't started yet")
428     end
429   end
430
431   def show_order
432     announce _("%{uno} playing turn: %{players}") % {
433       :uno => UNO, :players => players.join(' ')
434     }
435   end
436
437   def show_turn(opts={})
438     cards = true
439     cards = opts[:cards] if opts.key?(:cards)
440     player = @players.first
441     announce _("it's %{player}'s turn") % { :player => player }
442     show_user_cards(player) if cards
443   end
444
445   def has_turn?(source)
446     @players.first.user == source
447   end
448
449   def show_picker
450     if @picker > 0
451       announce _("next player must respond correctly or pick %{b}%{n}%{b} cards") % {
452         :b => Bold, :n => @picker
453       }
454     end
455   end
456
457   def show_discard
458     announce _("Current discard: %{card} %{c}") % { :card => @discard,
459       :c => (Wild === @discard) ? UnoGame.irc_color_bg(@color) + " #{@color} " : nil
460     }
461     show_picker
462   end
463
464   def show_user_cards(player)
465     p = Player === player ? player : get_player(player)
466     notify p, _('Your cards: %{cards}') % {
467       :cards => p.cards.join(' ')
468     }
469   end
470
471   def show_all_cards(u=nil)
472     announce(@players.inject([]) { |list, p|
473       list << [p, p.cards.length].join(': ')
474     }.join(', '))
475     if u
476       show_user_cards(u)
477     end
478   end
479
480   def pick_card(user)
481     p = get_player(user)
482     announce _("%{player} picks a card") % { :player => p }
483     deal(p, 1)
484     @player_has_picked = true
485   end
486
487   def deal(player, num=1)
488     picked = []
489     num.times do
490       picked << @stock.delete_one
491       if @stock.length == 0
492         announce _("Shuffling discarded cards")
493         make_stock
494         if @stock.length == 0
495           announce _("No more cards!")
496           end_game # FIXME nope!
497         end
498       end
499     end
500     picked.sort!
501     notify player, _("You picked %{picked}") % { :picked => picked.join(' ') }
502     player.cards += picked
503     player.cards.sort!
504   end
505
506   def add_player(user)
507     if p = get_player(user)
508       announce _("you're already in the game, %{p}") % {
509         :p => p
510       }
511       return
512     end
513     @dropouts.each do |dp|
514       if dp.user == user
515         announce _("you dropped from the game, %{p}, you can't get back in") % {
516           :p => dp
517         }
518         return
519       end
520     end
521     cards = 7
522     if @start_time
523       cards = @players.inject(0) do |s, pl|
524         s +=pl.cards.length
525       end/@players.length
526     end
527     p = Player.new(user)
528     @players << p
529     announce _("%{p} joins this game of %{uno}") % {
530       :p => p, :uno => UNO
531     }
532     deal(p, cards)
533     return if @start_time
534     if @join_timer
535       @bot.timer.reschedule(@join_timer, 10)
536     elsif @players.length > 1
537       announce _("game will start in 20 seconds")
538       @join_timer = @bot.timer.add_once(20) {
539         start_game
540       }
541     end
542   end
543
544   def drop_player(user)
545     unless p = get_player(user)
546       announce _("%{p} isn't playing %{uno}") % {
547         :p => p, :uno => UNO
548       }
549       return
550     end
551     announce _("%{p} gives up this game of %{uno}") % {
552       :p => p, :uno => UNO
553     }
554     if @players.length == 2
555       if p == @players.first
556         next_turn
557       end
558       end_game
559       return
560     end
561     debug @stock.length
562     while p.cards.length > 0
563       @stock.insert(rand(@stock.length), p.cards.shift)
564     end
565     debug @stock.length
566     @dropouts << @players.delete_one(p)
567   end
568
569   def replace_player(old, new)
570     # The new user
571     user = channel.get_user(new)
572     if p = get_player(user)
573       announce _("%{p} is already playing %{uno} here") % {
574         :p => p, :uno => UNO
575       }
576       return
577     end
578     # We scan the player list of the player with the old nick, instead
579     # of using get_player, in case of IRC drops etc
580     @players.each do |p|
581       if p.user.nick == old
582         p.user = user
583         announce _("%{p} takes %{b}%{old}%{b}'s place at %{uno}") % {
584           :p => p, :b => Bold, :old => old, :uno => UNO
585         }
586         return
587       end
588     end
589     announce _("%{b}%{old}%{b} isn't playing %{uno} here") % {
590       :uno => UNO, :b => Bold, :old => old
591     }
592   end
593
594   def end_game(halted = false)
595     if halted
596       announce _("%{uno} game halted after %{time}") % {
597         :time => Utils.secs_to_string(Time.now-@start_time),
598         :uno => UNO
599       }
600     else
601       announce _("%{uno} game finished after %{time}! The winner is %{p}") % {
602         :time => Utils.secs_to_string(Time.now-@start_time),
603         :uno => UNO, :p => @players.first
604       }
605     end
606     if @picker > 0 and not halted
607       p = @players[1]
608       announce _("%{p} has to pick %{b}%{n}%{b} cards!") % {
609         :p => p, :n => @picker, :b => Bold
610       }
611       deal(p, @picker)
612       @picker = 0
613     end
614     score = @players.inject(0) do |sum, p|
615       if p.cards.length > 0
616         announce _("%{p} still had %{cards}") % {
617           :p => p, :cards => p.cards.join(' ')
618         }
619         sum += p.cards.inject(0) do |cs, c|
620           cs += c.score
621         end
622       end
623       sum
624     end
625     if not halted
626       announce _("%{p} wins with %{b}%{score}%{b} points!") % {
627         :p => @players.first, :score => score, :b => Bold
628       }
629     end
630     @plugin.do_end_game(@channel)
631   end
632
633 end
634
635 class UnoPlugin < Plugin
636   attr :games
637   def initialize
638     super
639     @games = {}
640   end
641
642   def help(plugin, topic="")
643     case topic
644     when 'commands'
645       [
646       _("'jo' to join in"),
647       _("'pl <card>' to play <card>: e.g. 'pl g7' to play Green 7, or 'pl rr' to play Red Reverse"),
648       _("'pe' to pick a card"),
649       _("'pa' to pass your turn"),
650       _("'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)"),
651       _("'ca' to show current cards"),
652       _("'cd' to show the current discard"),
653       _("'od' to show the playing order"),
654       _("'ti' to show play time"),
655       _("'tu' to show whose turn it is")
656     ].join(" ; ")
657     when 'rules'
658       _("play all your cards, one at a time, by matching either the color or the value of the currently discarded card. ") +
659       _("cards with special effects: Skip (next player skips a turn), Reverse (reverses the playing order), +2 (next player has to take 2 cards). ") +
660       _("Wilds can be played on any card, and you must specify the color for the next card. ") +
661       _("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. ") +
662       _("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. ") +
663       _("you can also play a Reverse on a +2 or +4, bouncing the effect back to the previous player (that now comes next). ")
664     else
665       (_("%{uno} game. !uno to start a game. see help uno rules for the rules. commands: %{cmds}") % {
666         :uno => UnoGame::UNO,
667         :cmds => help(plugin, 'commands')
668       })
669     end
670   end
671
672   def message(m)
673     return unless @games.key?(m.channel)
674     g = @games[m.channel]
675     case m.plugin.intern
676     when :jo # join game
677       return if m.params
678       g.add_player(m.source)
679     when :pe # pick card
680       return if m.params
681       if g.has_turn?(m.source)
682         if g.player_has_picked
683           m.reply _("you already picked a card")
684         elsif g.picker > 0
685           m.reply _("you can't pick a card")
686         else
687           g.pick_card(m.source)
688         end
689       else
690         m.reply _("It's not your turn")
691       end
692     when :pa # pass turn
693       return if m.params
694       if g.has_turn?(m.source)
695         g.pass(m.source)
696       else
697         m.reply _("It's not your turn")
698       end
699     when :pl # play card
700       if g.has_turn?(m.source)
701         g.play_card(m.source, m.params.downcase)
702       else
703         m.reply _("It's not your turn")
704       end
705     when :co # pick color
706       if g.has_turn?(m.source)
707         g.choose_color(m.source, m.params.downcase)
708       else
709         m.reply _("It's not your turn")
710       end
711     when :ca # show current cards
712       return if m.params
713       g.show_all_cards(m.source)
714     when :cd # show current discard
715       return if m.params
716       g.show_discard
717     # TODO
718     # when :ch
719     #   g.challenge
720     when :od # show playing order
721       return if m.params
722       g.show_order
723     when :ti # show play time
724       return if m.params
725       g.show_time
726     when :tu # show whose turn is it
727       return if m.params
728       if g.has_turn?(m.source)
729         m.nickreply _("it's your turn, sleepyhead")
730       else
731         g.show_turn(:cards => false)
732       end
733     end
734   end
735
736   def create_game(m, p)
737     if @games.key?(m.channel)
738       m.reply _("There is already an %{uno} game running here, say 'jo' to join in") % { :uno => UnoGame::UNO }
739       return
740     end
741     @games[m.channel] = UnoGame.new(self, m.channel)
742     m.reply _("Ok, created %{uno} game on %{channel}, say 'jo' to join in") % {
743       :uno => UnoGame::UNO,
744       :channel => m.channel
745     }
746   end
747
748   def end_game(m, p)
749     unless @games.key?(m.channel)
750       m.reply _("There is no %{uno} game running here") % { :uno => UnoGame::UNO }
751       return
752     end
753     @games[m.channel].end_game(true)
754   end
755
756   def do_end_game(channel)
757     @games.delete(channel)
758   end
759
760   def replace_player(m, p)
761     unless @games.key?(m.channel)
762       m.reply _("There is no %{uno} game running here") % { :uno => UnoGame::UNO }
763       return
764     end
765     @games[m.channel].replace_player(p[:old], p[:new])
766   end
767
768   def drop_player(m, p)
769     unless @games.key?(m.channel)
770       m.reply _("There is no %{uno} game running here") % { :uno => UnoGame::UNO }
771       return
772     end
773     who = p[:nick] ? m.channel.get_user(p[:nick]) : m.source
774     @games[m.channel].drop_player(who)
775   end
776
777   def print_stock(m, p)
778     unless @games.key?(m.channel)
779       m.reply _("There is no %{uno} game running here") % { :uno => UnoGame::UNO }
780       return
781     end
782     stock = @games[m.channel].stock
783     m.reply(_("%{num} cards in stock: %{stock}") % {
784       :num => stock.length,
785       :stock => stock.join(' ')
786     }, :split_at => /#{NormalText}\s*/)
787   end
788 end
789
790 pg = UnoPlugin.new
791
792 pg.map 'uno', :private => false, :action => :create_game
793 pg.map 'uno end', :private => false, :action => :end_game
794 pg.map 'uno drop', :private => false, :action => :drop_player
795 pg.map 'uno giveup', :private => false, :action => :drop_player
796 pg.map 'uno drop :nick', :private => false, :action => :drop_player, :auth_path => ':other'
797 pg.map 'uno replace :old [with] :new', :private => false, :action => :replace_player
798 pg.map 'uno stock', :private => false, :action => :print_stock
799
800 pg.default_auth('stock', false)
801 pg.default_auth('end', false)
802 pg.default_auth('drop::other', false)
803 pg.default_auth('replace', false)