]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - data/rbot/plugins/games/uno.rb
uno plugin: initial implementation of UNO! game, no endgame yet
[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 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       @user.to_s
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.user
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.user
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     else
257       @picker = 0
258     end
259     if card.special?
260       @special = true
261     else
262       @special = false
263     end
264   end
265
266   def next_turn(opts={})
267     @players << @players.shift
268     @player_has_picked = false
269     show_turn
270   end
271
272   def can_play(card)
273     # When a +something is online, you can only play
274     # a +something of same or higher something, or a Reverse
275     # TODO make optional
276     if @picker > 0
277       if card.value == 'Reverse' or card.picker >= @discard.picker
278         return true
279       else
280         return false
281       end
282     else
283       # You can always play a Wild
284       # FIXME W+4 can only be played if you don't have a proper card
285       # TODO make it playable anyway, and allow players to challenge
286       return true if Wild === card
287       # On a Wild, you must match the color
288       if Wild === @discard
289         return card.color == @color
290       else
291         # Otherwise, you can match either the value or the color
292         return (card.value == @value) || (card.color == @color)
293       end
294     end
295   end
296
297   def play_card(source, cards)
298     debug "Playing card #{cards}"
299     p = get_player(source)
300     shorts = cards.scan(/[rbgy]\s*(?:\+?\d|[rs])|w\s*(?:\+4)?/)
301     debug shorts.inspect
302     if shorts.length > 2 or shorts.length < 1
303       announce _("you can only play one or two cards")
304       return
305     end
306     if shorts.length == 2 and shorts.first != shorts.last
307       announce _("you can only play two cards if they are the same")
308       return
309     end
310     if cards = p.has_card?(shorts.first)
311       debug cards
312       unless can_play(cards.first)
313         announce _("you can't play that card")
314         return
315       end
316       if cards.length >= shorts.length
317         set_discard(p.cards.delete_one(cards.shift))
318         if shorts.length > 1
319           set_discard(p.cards.delete_one(cards.shift))
320           announce _("%{p} plays %{card} twice!") % {
321             :p => source,
322             :card => @discard
323           }
324         else
325           announce _("%{p} plays %{card}") % { :p => source, :card => @discard }
326         end
327         if p.cards.length == 1
328           announce _("%{p} has %{uno}!") % {
329             :p => source, :uno => UNO
330           }
331         elsif p.cards.length == 0
332           end_game
333           return
334         end
335         show_picker
336         if @color
337           if @special
338             do_special
339           end
340           next_turn
341         else
342           announce _("%{p}, choose a color with: co r|b|g|y") % {
343             :p => p.user
344           }
345         end
346       else
347         announce _("you don't have that card")
348       end
349     end
350   end
351
352   def pass(user)
353     p = get_player(user)
354     if @picker > 0
355       announce _("%{p} passes turn, and has to pick %{b}%{n}%{b} cards!") % {
356         :p => user, :b => Bold, :n => @picker
357       }
358       deal(p, @picker)
359       @picker = 0
360     else
361       if @player_has_picked
362         announce _("%{p} passes turn") % { :p => user }
363       else
364         announce _("you need to pick a card first")
365         return
366       end
367     end
368     next_turn
369   end
370
371   def choose_color(user, color)
372     case color
373     when 'r'
374       @color = 'Red'
375     when 'b'
376       @color = 'Blue'
377     when 'g'
378       @color = 'Green'
379     when 'y'
380       @color = 'Yellow'
381     else
382       announce _('what color is that?')
383       return
384     end
385     announce _('color is now %{c}') % { :c => @color.downcase }
386     next_turn
387   end
388
389   def show_time
390     if @start_time
391       announce _("This %{uno} game has been going on for %{time}") % {
392         :uno => UNO,
393         :time => Utils.secs_to_string(Time.now - @start_time)
394       }
395     else
396       announce _("The game hasn't started yet")
397     end
398   end
399
400   def show_order
401     announce _("%{uno} playing turn: %{players}") % {
402       :uno => UNO, :players => players.join(' ')
403     }
404   end
405
406   def show_turn(opts={})
407     cards = true
408     cards = opts[:cards] if opts.key?(:cards)
409     player = @players.first
410     announce _("it's %{player}'s turn") % { :player => player.user }
411     show_user_cards(player) if cards
412   end
413
414   def has_turn?(source)
415     @players.first.user == source
416   end
417
418   def show_picker
419     if @picker > 0
420       announce _("next player must respond correctly or pick %{b}%{n}%{b} cards") % {
421         :b => Bold, :n => @picker
422       }
423     end
424   end
425
426   def show_discard
427     announce _("Current discard: %{card} %{c}") % { :card => @discard,
428       :c => (Wild === @discard) ? UnoGame.irc_color_bg(@color) + " #{@color} " : nil
429     }
430     show_picker
431   end
432
433   def show_user_cards(player)
434     p = Player === player ? player : get_player(player)
435     notify p, _('Your cards: %{cards}') % {
436       :cards => p.cards.join(' ')
437     }
438   end
439
440   def show_all_cards(u=nil)
441     announce(@players.inject([]) { |list, p|
442       list << [p.user, p.cards.length].join(': ')
443     }.join(', '))
444     if u
445       show_user_cards(u)
446     end
447   end
448
449   def pick_card(user)
450     p = get_player(user)
451     announce _("%{player} picks a card") % { :player => p }
452     deal(p, 1)
453     @player_has_picked = true
454   end
455
456   def deal(player, num=1)
457     picked = []
458     num.times do
459       picked << @stock.delete_one
460       if @stock.length == 0
461         announce _("Shuffling discarded cards")
462         make_stock
463         if @stock.length == 0
464           announce _("No more cards!")
465           end_game # FIXME nope!
466         end
467       end
468     end
469     notify player, _("You picked %{picked}") % { :picked => picked.join(' ') }
470     player.cards += picked
471   end
472
473   def add_player(user)
474     return if get_player(user)
475     p = Player.new(user)
476     @players << p
477     deal(p, 7)
478     if @join_timer
479       @bot.timer.reschedule(@join_timer, 10)
480     elsif @players.length > 1
481       announce _("game will start in 20 seconds")
482       @join_timer = @bot.timer.add_once(20) {
483         start_game
484       }
485     end
486   end
487
488   def end_game
489     announce _('TODO end game')
490     @plugin.end_game(@channel)
491   end
492
493 end
494
495 class UnoPlugin < Plugin
496   attr :games
497   def initialize
498     super
499     @games = {}
500   end
501
502   def help(plugin, topic="")
503     (_("%{uno} game. !uno to start a game. in-game commands (no prefix): ") % {
504       :uno => UnoGame::UNO
505     }) + [
506       _("'jo' to join in"),
507       _("'pl <card>' to play <card>"),
508       _("'pe' to pick a card"),
509       _("'pa' to pass your turn"),
510       _("'co <color>' to pick a color"),
511       _("'ca' to show current cards"),
512       _("'cd' to show the current discard"),
513       _("'od' to show the playing order"),
514       _("'ti' to show play time"),
515       _("'tu' to show whose turn it is")
516     ].join(" ; ")
517   end
518
519   def message(m)
520     return unless @games.key?(m.channel)
521     g = @games[m.channel]
522     case m.plugin.intern
523     when :jo # join game
524       g.add_player(m.source)
525     when :pe # pick card
526       if g.has_turn?(m.source)
527         if g.player_has_picked
528           m.reply _("you already picked a card")
529         elsif g.picker > 0
530           m.reply _("you can't pick a card")
531         else
532           g.pick_card(m.source)
533         end
534       else
535         m.reply _("It's not your turn")
536       end
537     when :pa # pass turn
538       if g.has_turn?(m.source)
539         g.pass(m.source)
540       else
541         m.reply _("It's not your turn")
542       end
543     when :pl # play card
544       if g.has_turn?(m.source)
545         g.play_card(m.source, m.params)
546       else
547         m.reply _("It's not your turn")
548       end
549     when :co # pick color
550       if g.has_turn?(m.source)
551         g.choose_color(m.source, m.params.downcase)
552       else
553         m.reply _("It's not your turn")
554       end
555     when :ca # show current cards
556       g.show_all_cards(m.source)
557     when :cd # show current discard
558       g.show_discard
559     # TODO
560     # when :ch
561     #   g.challenge
562     when :od # show playing order
563       g.show_order
564     when :ti # show play time
565       g.show_time
566     when :tu # show whose turn is it
567       if g.has_turn?(m.source)
568         m.nickreply _("it's your turn, sleepyhead")
569       else
570         g.show_turn(:cards => false)
571       end
572     end
573   end
574
575   def create_game(m, p)
576     if @games.key?(m.channel)
577       m.reply _("There is already an %{uno} game running here, say 'jo' to join in") % { :uno => UnoGame::UNO }
578       return
579     end
580     @games[m.channel] = UnoGame.new(self, m.channel)
581     m.reply _("Ok, created %{uno} game on %{channel}, say 'jo' to join in") % {
582       :uno => UnoGame::UNO,
583       :channel => m.channel
584     }
585   end
586
587   def end_game(channel)
588     @games.delete(channel)
589   end
590
591   def print_stock(m, p)
592     unless @games.key?(m.channel)
593       m.reply _("There is no %{uno} game running here") % { :uno => UnoGame::UNO }
594       return
595     end
596     stock = @games[m.channel].stock
597     m.reply(_("%{num} cards in stock: %{stock}") % {
598       :num => stock.length,
599       :stock => stock.join(' ')
600     }, :split_at => /#{NormalText}\s*/)
601   end
602 end
603
604 pg = UnoPlugin.new
605
606 pg.map 'uno', :private => false, :action => :create_game
607 pg.map 'uno stock', :private => false, :action => :print_stock
608 pg.default_auth('stock', false)