]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - data/rbot/plugins/games/uno.rb
ead3aeb72670d611c45f34770550943d6bec0ca5
[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.scan(/[rbgy]\s*(?:\+?\d|[rs])|w\s*(?:\+4)?/)
317     debug shorts.inspect
318     if shorts.length > 2 or shorts.length < 1
319       announce _("you can only play one or two cards")
320       return
321     end
322     if shorts.length == 2 and shorts.first != shorts.last
323       announce _("you can only play two cards if they are the same")
324       return
325     end
326     if cards = p.has_card?(shorts.first)
327       debug cards
328       unless can_play(cards.first)
329         announce _("you can't play that card")
330         return
331       end
332       if cards.length >= shorts.length
333         set_discard(p.cards.delete_one(cards.shift))
334         if shorts.length > 1
335           set_discard(p.cards.delete_one(cards.shift))
336           announce _("%{p} plays %{card} twice!") % {
337             :p => p,
338             :card => @discard
339           }
340         else
341           announce _("%{p} plays %{card}") % { :p => p, :card => @discard }
342         end
343         if p.cards.length == 1
344           announce _("%{p} has %{uno}!") % {
345             :p => p, :uno => UNO
346           }
347         elsif p.cards.length == 0
348           end_game
349           return
350         end
351         show_picker
352         if @color
353           if @special
354             do_special
355           end
356           next_turn
357         else
358           announce _("%{p}, choose a color with: co r|b|g|y") % { :p => p }
359         end
360       else
361         announce _("you don't have two cards of that kind")
362       end
363     else
364       announce _("you don't have that card")
365     end
366   end
367
368   def pass(user)
369     p = get_player(user)
370     if @picker > 0
371       announce _("%{p} passes turn, and has to pick %{b}%{n}%{b} cards!") % {
372         :p => p, :b => Bold, :n => @picker
373       }
374       deal(p, @picker)
375       @picker = 0
376     else
377       if @player_has_picked
378         announce _("%{p} passes turn") % { :p => p }
379       else
380         announce _("you need to pick a card first")
381         return
382       end
383     end
384     next_turn
385   end
386
387   def choose_color(user, color)
388     case color
389     when 'r'
390       @color = 'Red'
391     when 'b'
392       @color = 'Blue'
393     when 'g'
394       @color = 'Green'
395     when 'y'
396       @color = 'Yellow'
397     else
398       announce _('what color is that?')
399       return
400     end
401     announce _('color is now %{c}') % {
402       :c => UnoGame.irc_color_bg(@color)+" #{@color} "
403     }
404     next_turn
405   end
406
407   def show_time
408     if @start_time
409       announce _("This %{uno} game has been going on for %{time}") % {
410         :uno => UNO,
411         :time => Utils.secs_to_string(Time.now - @start_time)
412       }
413     else
414       announce _("The game hasn't started yet")
415     end
416   end
417
418   def show_order
419     announce _("%{uno} playing turn: %{players}") % {
420       :uno => UNO, :players => players.join(' ')
421     }
422   end
423
424   def show_turn(opts={})
425     cards = true
426     cards = opts[:cards] if opts.key?(:cards)
427     player = @players.first
428     announce _("it's %{player}'s turn") % { :player => player }
429     show_user_cards(player) if cards
430   end
431
432   def has_turn?(source)
433     @players.first.user == source
434   end
435
436   def show_picker
437     if @picker > 0
438       announce _("next player must respond correctly or pick %{b}%{n}%{b} cards") % {
439         :b => Bold, :n => @picker
440       }
441     end
442   end
443
444   def show_discard
445     announce _("Current discard: %{card} %{c}") % { :card => @discard,
446       :c => (Wild === @discard) ? UnoGame.irc_color_bg(@color) + " #{@color} " : nil
447     }
448     show_picker
449   end
450
451   def show_user_cards(player)
452     p = Player === player ? player : get_player(player)
453     notify p, _('Your cards: %{cards}') % {
454       :cards => p.cards.join(' ')
455     }
456   end
457
458   def show_all_cards(u=nil)
459     announce(@players.inject([]) { |list, p|
460       list << [p, p.cards.length].join(': ')
461     }.join(', '))
462     if u
463       show_user_cards(u)
464     end
465   end
466
467   def pick_card(user)
468     p = get_player(user)
469     announce _("%{player} picks a card") % { :player => p }
470     deal(p, 1)
471     @player_has_picked = true
472   end
473
474   def deal(player, num=1)
475     picked = []
476     num.times do
477       picked << @stock.delete_one
478       if @stock.length == 0
479         announce _("Shuffling discarded cards")
480         make_stock
481         if @stock.length == 0
482           announce _("No more cards!")
483           end_game # FIXME nope!
484         end
485       end
486     end
487     picked.sort!
488     notify player, _("You picked %{picked}") % { :picked => picked.join(' ') }
489     player.cards += picked
490     player.cards.sort!
491   end
492
493   def add_player(user)
494     if p = get_player(user)
495       announce _("you're already in the game, %{p}") % {
496         :p => p
497       }
498       return
499     end
500     p = Player.new(user)
501     @players << p
502     announce _("%{p} joins this game of %{uno}") % {
503       :p => p, :uno => UNO
504     }
505     deal(p, 7)
506     if @join_timer
507       @bot.timer.reschedule(@join_timer, 10)
508     elsif @players.length > 1
509       announce _("game will start in 20 seconds")
510       @join_timer = @bot.timer.add_once(20) {
511         start_game
512       }
513     end
514   end
515
516   def end_game
517     announce _("%{uno} game finished after %{time}! The winner is %{p}") % {
518       :time => Utils.secs_to_string(Time.now-@start_time),
519       :uno => UNO, :p => @players.first
520     }
521     if @picker > 0
522       p = @players[1]
523       announce _("%{p} has to pick %{b}%{n}%{b} cards!") % {
524         :p => p, :n => @picker, :b => Bold
525       }
526       deal(p, @picker)
527       @picker = 0
528     end
529     score = @players.inject(0) do |sum, p|
530       if p.cards.length > 0
531         announce _("%{p} still had %{cards}") % {
532           :p => p, :cards => p.cards.join(' ')
533         }
534         sum += p.cards.inject(0) do |cs, c|
535           cs += c.score
536         end
537       end
538       sum
539     end
540     announce _("%{p} wins with %{b}%{score}%{b} points!") % {
541         :p => @players.first, :score => score, :b => Bold
542     }
543     @plugin.end_game(@channel)
544   end
545
546 end
547
548 class UnoPlugin < Plugin
549   attr :games
550   def initialize
551     super
552     @games = {}
553   end
554
555   def help(plugin, topic="")
556     (_("%{uno} game. !uno to start a game. in-game commands (no prefix): ") % {
557       :uno => UnoGame::UNO
558     }) + [
559       _("'jo' to join in"),
560       _("'pl <card>' to play <card>"),
561       _("'pe' to pick a card"),
562       _("'pa' to pass your turn"),
563       _("'co <color>' to pick a color"),
564       _("'ca' to show current cards"),
565       _("'cd' to show the current discard"),
566       _("'od' to show the playing order"),
567       _("'ti' to show play time"),
568       _("'tu' to show whose turn it is")
569     ].join(" ; ")
570   end
571
572   def message(m)
573     return unless @games.key?(m.channel)
574     g = @games[m.channel]
575     case m.plugin.intern
576     when :jo # join game
577       return if m.params
578       g.add_player(m.source)
579     when :pe # pick card
580       return if m.params
581       if g.has_turn?(m.source)
582         if g.player_has_picked
583           m.reply _("you already picked a card")
584         elsif g.picker > 0
585           m.reply _("you can't pick a card")
586         else
587           g.pick_card(m.source)
588         end
589       else
590         m.reply _("It's not your turn")
591       end
592     when :pa # pass turn
593       return if m.params
594       if g.has_turn?(m.source)
595         g.pass(m.source)
596       else
597         m.reply _("It's not your turn")
598       end
599     when :pl # play card
600       if g.has_turn?(m.source)
601         g.play_card(m.source, m.params.downcase)
602       else
603         m.reply _("It's not your turn")
604       end
605     when :co # pick color
606       if g.has_turn?(m.source)
607         g.choose_color(m.source, m.params.downcase)
608       else
609         m.reply _("It's not your turn")
610       end
611     when :ca # show current cards
612       return if m.params
613       g.show_all_cards(m.source)
614     when :cd # show current discard
615       return if m.params
616       g.show_discard
617     # TODO
618     # when :ch
619     #   g.challenge
620     when :od # show playing order
621       return if m.params
622       g.show_order
623     when :ti # show play time
624       return if m.params
625       g.show_time
626     when :tu # show whose turn is it
627       return if m.params
628       if g.has_turn?(m.source)
629         m.nickreply _("it's your turn, sleepyhead")
630       else
631         g.show_turn(:cards => false)
632       end
633     end
634   end
635
636   def create_game(m, p)
637     if @games.key?(m.channel)
638       m.reply _("There is already an %{uno} game running here, say 'jo' to join in") % { :uno => UnoGame::UNO }
639       return
640     end
641     @games[m.channel] = UnoGame.new(self, m.channel)
642     m.reply _("Ok, created %{uno} game on %{channel}, say 'jo' to join in") % {
643       :uno => UnoGame::UNO,
644       :channel => m.channel
645     }
646   end
647
648   def end_game(channel)
649     @games.delete(channel)
650   end
651
652   def print_stock(m, p)
653     unless @games.key?(m.channel)
654       m.reply _("There is no %{uno} game running here") % { :uno => UnoGame::UNO }
655       return
656     end
657     stock = @games[m.channel].stock
658     m.reply(_("%{num} cards in stock: %{stock}") % {
659       :num => stock.length,
660       :stock => stock.join(' ')
661     }, :split_at => /#{NormalText}\s*/)
662   end
663 end
664
665 pg = UnoPlugin.new
666
667 pg.map 'uno', :private => false, :action => :create_game
668 pg.map 'uno stock', :private => false, :action => :print_stock
669 pg.default_auth('stock', false)