ant's friend's UNO\! changes
[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 # TODO documentation
15 # TODO allow full form card names for play
16 # TODO allow choice of rules re stacking + and playing Reverse with them
17
18 class UnoGame
19   COLORS = %w{Red Green Blue Yellow}
20   SPECIALS = %w{+2 Reverse Skip}
21   NUMERICS = (0..9).to_a
22   VALUES = NUMERICS + SPECIALS
23
24   def UnoGame.color_map(clr)
25     case clr
26     when 'Red'
27       :red
28     when 'Blue'
29       :royal_blue
30     when 'Green'
31       :limegreen
32     when 'Yellow'
33       :yellow
34     end
35   end
36
37   def UnoGame.irc_color_bg(clr)
38     Irc.color([:white,:black][COLORS.index(clr)%2],UnoGame.color_map(clr))
39   end
40
41   def UnoGame.irc_color_fg(clr)
42     Irc.color(UnoGame.color_map(clr))
43   end
44
45   def UnoGame.colorify(str, fg=false)
46     ret = Bold.dup
47     str.length.times do |i|
48       ret << (fg ?
49               UnoGame.irc_color_fg(COLORS[i%4]) :
50               UnoGame.irc_color_bg(COLORS[i%4]) ) +str[i,1]
51     end
52     ret << NormalText
53   end
54
55   UNO = UnoGame.colorify('UNO!', true)
56
57   # Colored play cards
58   class Card
59     attr_reader :color
60     attr_reader :value
61     attr_reader :shortform
62     attr_reader :to_s
63     attr_reader :score
64
65     def initialize(color, value)
66       raise unless COLORS.include? color
67       @color = color.dup
68       raise unless VALUES.include? value
69       if NUMERICS.include? value
70         @value = value
71         @score = value
72       else
73         @value = value.dup
74         @score = 20
75       end
76       if @value == '+2'
77         @shortform = (@color[0,1]+@value).downcase
78       else
79         @shortform = (@color[0,1]+@value.to_s[0,1]).downcase
80       end
81       @to_s = UnoGame.irc_color_bg(@color) +
82         Bold + ['', @color, @value, ''].join(' ') + NormalText
83     end
84
85     def picker
86       return 0 unless @value.to_s[0,1] == '+'
87       return @value[1,1].to_i
88     end
89
90     def special?
91       SPECIALS.include?(@value)
92     end
93
94     def <=>(other)
95       cc = self.color <=> other.color
96       if cc == 0
97         return self.value.to_s <=> other.value.to_s
98       else
99         return cc
100       end
101     end
102     include Comparable
103   end
104
105   # Wild, Wild +4 cards
106   class Wild < Card
107     def initialize(value=nil)
108       @color = 'Wild'
109       raise if value and not value == '+4'
110       if value
111         @value = value.dup 
112         @shortform = 'w'+value
113       else
114         @value = nil
115         @shortform = 'w'
116       end
117       @score = 50
118       @to_s = UnoGame.colorify(['', @color, @value, ''].compact.join(' '))
119     end
120     def special?
121       @value
122     end
123   end
124
125   class Player
126     attr_accessor :cards
127     attr_accessor :user
128     def initialize(user)
129       @user = user
130       @cards = []
131     end
132     def has_card?(short)
133       has = []
134       @cards.each { |c|
135         has << c if c.shortform == short
136       }
137       if has.empty?
138         return false
139       else
140         return has
141       end
142     end
143     def to_s
144       Bold + @user.to_s + Bold
145     end
146   end
147
148   # cards in stock
149   attr_reader :stock
150   # current discard
151   attr_reader :discard
152   # previous discard, in case of challenge
153   attr_reader :last_discard
154   # flag indicating someone played a w+4 (0 is no, 1 is yes)
155   attr_reader :w4_played_flag
156   # channel the game is played in
157   attr_reader :channel
158   # list of players
159   attr :players
160   # true if the player picked a card (and can thus pass turn)
161   attr_reader :player_has_picked
162   # number of cards to be picked if the player can't play an appropriate card
163   attr_reader :picker
164
165   # game start time
166   attr :start_time
167
168   # the IRC user that created the game
169   attr_accessor :manager
170
171   def initialize(plugin, channel, manager)
172     @channel = channel
173     @plugin = plugin
174     @bot = plugin.bot
175     @players = []
176     @dropouts = []
177     @discard = nil
178     @last_discard = nil
179     @w4_played_flag = 0
180     @value = nil
181     @color = nil
182     make_base_stock
183     @stock = []
184     make_stock
185     @start_time = nil
186     @join_timer = nil
187     @picker = 0
188     @last_picker = 0
189     @must_play = nil
190     @manager = manager
191   end
192
193   def get_player(user)
194     case user
195     when User
196       @players.each do |p|
197         return p if p.user == user
198       end
199     when String
200       @players.each do |p|
201         return p if p.user.irc_downcase == user.irc_downcase(channel.casemap)
202       end
203     else
204       get_player(user.to_s)
205     end
206     return nil
207   end
208
209   def announce(msg, opts={})
210     @bot.say channel, msg, opts
211   end
212
213   def notify(player, msg, opts={})
214     @bot.notice player.user, msg, opts
215   end
216
217   def make_base_stock
218     @base_stock = COLORS.inject([]) do |list, clr|
219       VALUES.each do |n|
220         list << Card.new(clr, n)
221         list << Card.new(clr, n) unless n == 0
222       end
223       list
224     end
225     4.times do
226       @base_stock << Wild.new
227       @base_stock << Wild.new('+4')
228     end
229   end
230
231   def make_stock
232     @stock.replace @base_stock
233     # remove the cards in the players hand
234     @players.each { |p| p.cards.each { |c| @stock.delete_one c } }
235     # remove current top discarded card if present
236     if @discard
237       @stock.delete_one(discard)
238     end
239     @stock.shuffle!
240   end
241
242   def start_game
243     debug "Starting game"
244     @players.shuffle!
245     show_order
246     announce _("%{p} deals the first card from the stock") % {
247       :p => @players.first
248     }
249     card = @stock.shift
250     @picker = 0
251     @special = false
252     while Wild === card do
253       @stock.insert(rand(@stock.length), card)
254       card = @stock.shift
255     end
256     set_discard(card)
257     show_discard
258     if @special
259       do_special
260     end
261     next_turn
262     @start_time = Time.now
263   end
264
265   def elapsed_time
266     if @start_time
267       Utils.secs_to_string(Time.now-@start_time)
268     else
269       _("no time")
270     end
271   end
272
273   def reverse_turn
274     # if there are two players, the Reverse acts like a Skip, unless
275     # there's a @picker running, in which case the Reverse should bounce the
276     # pick on the other player
277     if @players.length > 2
278       @players.reverse!
279       # put the current player back in its place
280       @players.unshift @players.pop
281       announce _("Playing order was reversed!")
282     elsif @picker > 0
283       announce _("%{cp} bounces the pick to %{np}") % {
284         :cp => @players.first,
285         :np => @players.last
286       }
287     else
288       skip_turn
289     end
290   end
291
292   def skip_turn
293     @players << @players.shift
294     announce _("%{p} skips a turn!") % {
295       # this is first and not last because the actual
296       # turn change will be done by the following next_turn
297       :p => @players.first
298     }
299   end
300
301   def do_special
302     case @discard.value
303     when 'Reverse'
304       reverse_turn
305       @special = false
306     when 'Skip'
307       skip_turn
308       @special = false
309     end
310   end
311
312   def set_discard(card)
313     @discard = card
314     @value = card.value.dup rescue card.value
315     if Wild === card
316       @color = nil
317     else
318       @color = card.color.dup
319     end
320     if card.picker > 0
321       @picker += card.picker
322       @last_picker = @discard.picker
323     end
324     if card.special?
325       @special = true
326     else
327       @special = false
328     end
329     @must_play = nil
330   end
331
332   def next_turn(opts={})
333     @players << @players.shift
334     @player_has_picked = false
335     show_turn
336   end
337
338   def can_play(card)
339     # if play is forced, check against the only allowed cards
340     return false if @must_play and not @must_play.include?(card)
341
342     if @picker > 0
343       # During a picker run (i.e. after a +something was played and before a
344       # player is forced to pick) you can only play pickers (+2, +4) and
345       # Reverse. Reverse can be played if the previous card matches by color or
346       # value (as usual), a +4 can always be played, a +2 can be played on a +2
347       # of any color or on a Reverse of the correct color unless a +4 was
348       # played on it
349       # TODO make optional
350       case card.value
351       when 'Reverse'
352         # Reverse can be played if it matches color or value
353         return (card.color == @color) || (@discard.value == card.value)
354       when '+2'
355         return false if @last_picker > 2
356         return true if @discard.value == card.value
357         return true if @discard.value == 'Reverse' and @color == card.color
358         return false
359       when '+4'
360         return true
361       else
362         return false
363       end
364     else
365       # You can always play a Wild
366       return true if Wild === card
367       # On a Wild, you must match the color
368       if Wild === @discard
369         return card.color == @color
370       else
371         # Otherwise, you can match either the value or the color
372         return (card.value == @value) || (card.color == @color)
373       end
374     end
375   end
376
377   def play_card(source, cards)
378     debug "Playing card #{cards}"
379     p = get_player(source)
380     shorts = cards.gsub(/\s+/,'').match(/^(?:([rbgy]\+?\d){1,2}|([rbgy][rs])|(w(?:\+4)?)([rbgy])?)$/).to_a
381     debug shorts.inspect
382     if shorts.empty?
383       announce _("what cards were that again?")
384       return
385     end
386     full = shorts[0]
387     short = shorts[1] || shorts[2] || shorts[3]
388     jolly = shorts[3]
389     jcolor = shorts[4]
390     if jolly
391       toplay = 1
392     else
393       toplay = (full == short) ? 1 : 2
394     end
395     debug [full, short, jolly, jcolor, toplay].inspect
396     # r7r7 -> r7r7, r7, nil, nil
397     # r7 -> r7, r7, nil, nil
398     # w -> w, nil, w, nil
399     # wg -> wg, nil, w, g
400     if cards = p.has_card?(short)
401       debug cards
402       unless can_play(cards.first)
403         announce _("you can't play that card")
404         return
405       end
406       if cards.length >= toplay
407         # if the played card is a W+4 not played during a stacking +x
408         # TODO if A plays an illegal W+4, B plays a W+4, should the next
409         # player be able to challenge A? For the time being we say no,
410         # but I think he should, and in case A's move was illegal
411         # game would have to go back, A would get the penalty and replay,
412         # while if it was legal the challenger would get 50% more cards,
413         # i.e. 12 cards (or more if the stacked +4 were more). This would
414         # only be possible if the first W+4 was illegal, so it wouldn't
415         # apply for a W+4 played on a +2 anyway.
416         #
417         if @picker == 0 and Wild === cards.first and cards.first.value 
418           # save the previous discard in case of challenge
419           @last_discard = @discard.dup
420           # save the color too, in case it was a Wild
421           @last_color = @color.dup
422             @w4_played_flag = 1
423         else
424           # mark the move as not challengeable
425           @last_discard = nil
426           @last_color = nil
427             @w4_played_flag = 0
428         end
429         set_discard(p.cards.delete_one(cards.shift))
430         if toplay > 1
431           set_discard(p.cards.delete_one(cards.shift))
432           announce _("%{p} plays %{card} twice!") % {
433             :p => p,
434             :card => @discard
435           }
436         else
437           announce _("%{p} plays %{card}") % { :p => p, :card => @discard }
438         end
439         if p.cards.length == 1
440           announce _("%{p} has %{uno}!") % {
441             :p => p, :uno => UNO
442           }
443         elsif p.cards.length == 0
444           end_game
445           return
446         end
447         show_picker
448         if @color
449           if @special
450             do_special
451           end
452           next_turn
453         elsif jcolor
454           choose_color(p.user, jcolor)
455         else
456           announce _("%{p}, choose a color with: co r|b|g|y") % { :p => p }
457         end
458       else
459         announce _("you can't play that card")
460       end
461     else
462       announce _("you can't play that card")
463     end
464   end
465
466   def challenge
467     return unless @last_discard
468     # current player
469     cp = @players.first
470     # previous player
471     lp = @players.last
472     announce _("%{cp} challenges %{lp}'s %{card}!") % {
473       :cp => cp, :lp => lp, :card => @discard
474     }
475     # show the cards of the previous player to the current player
476     notify cp, _("%{p} has %{cards}") % {
477       :p => lp, :cards => lp.cards.join(' ')
478     }
479     # check if the previous player had a non-special card of the correct color
480     legal = true
481     lp.cards.each do |c|
482       if c.color == @last_color and not c.special?
483         legal = false
484       end
485     end
486     if legal
487       @picker += 2
488       announce _("%{lp}'s move was legal, %{cp} must pick %{b}%{n}%{b} cards!") % {
489         :cp => cp, :lp => lp, :b => Bold, :n => @picker
490       }
491       @last_color = nil
492       @last_discard = nil
493       deal(cp, @picker)
494       @picker = 0
495       next_turn
496     else
497       announce _("%{lp}'s move was %{b}not%{b} legal, %{lp} must pick %{b}%{n}%{b} cards and play again!") % {
498         :cp => cp, :lp => lp, :b => Bold, :n => @picker
499       }
500       lp.cards << @discard # put the W+4 back in place
501
502       # reset the discard
503       @color = @last_color.dup
504       @discard = @last_discard.dup
505       @special = false
506       @value = @discard.value.dup rescue @discard.value
507       @last_color = nil
508       @last_discard = nil
509
510       # force the player to play the current cards
511       @must_play = lp.cards.dup
512
513       # give him the penalty cards
514       deal(lp, @picker)
515       @picker = 0
516
517       # and restore the turn
518       @players.unshift @players.pop
519     end
520     @w4_played_flag = 0
521   end
522
523   def pass(user)
524     p = get_player(user)
525     if @picker > 0
526       announce _("%{p} passes turn, and has to pick %{b}%{n}%{b} cards!") % {
527         :p => p, :b => Bold, :n => @picker
528       }
529       deal(p, @picker)
530       @picker = 0
531     else
532       if @player_has_picked
533         announce _("%{p} passes turn") % { :p => p }
534       else
535         announce _("you need to pick a card first")
536         return
537       end
538     end
539     next_turn
540     @w4_played_flag = 0
541   end
542
543   def choose_color(user, color)
544     # you can only pick a color if the current color is unset
545     if @color
546       announce _("you can't pick a color now, %{p}") % {
547         :p => get_player(user)
548       }
549       return
550     end
551     case color
552     when 'r'
553       @color = 'Red'
554     when 'b'
555       @color = 'Blue'
556     when 'g'
557       @color = 'Green'
558     when 'y'
559       @color = 'Yellow'
560     else
561       announce _('what color is that?')
562       return
563     end
564     announce _('color is now %{c}') % {
565       :c => UnoGame.irc_color_bg(@color)+" #{@color} "
566     }
567     next_turn
568   end
569
570   def show_time
571     if @start_time
572       announce _("This %{uno} game has been going on for %{time}") % {
573         :uno => UNO,
574         :time => elapsed_time
575       }
576     else
577       announce _("The game hasn't started yet")
578     end
579   end
580
581   def show_order
582     announce _("%{uno} playing turn: %{players}") % {
583       :uno => UNO, :players => players.join(' ')
584     }
585   end
586
587   def show_turn(opts={})
588     cards = true
589     cards = opts[:cards] if opts.key?(:cards)
590     player = @players.first
591     announce _("it's %{player}'s turn") % { :player => player }
592     show_user_cards(player) if cards
593   end
594
595   def has_turn?(source)
596     @start_time && (@players.first.user == source)
597   end
598
599   def show_picker
600     if @picker > 0
601       announce _("next player must respond correctly or pick %{b}%{n}%{b} cards") % {
602         :b => Bold, :n => @picker
603       }
604     end
605   end
606
607   def show_discard
608     announce _("Current discard: %{card} %{c}") % { :card => @discard,
609       :c => (Wild === @discard) ? UnoGame.irc_color_bg(@color) + " #{@color} " : nil
610     }
611     show_picker
612   end
613
614   def show_user_cards(player)
615     p = Player === player ? player : get_player(player)
616     return unless p
617     notify p, _('Your cards: %{cards}') % {
618       :cards => p.cards.join(' ')
619     }
620   end
621
622   def show_all_cards(u=nil)
623     announce(@players.inject([]) { |list, p|
624       list << [p, p.cards.length].join(': ')
625     }.join(', '))
626     if u
627       show_user_cards(u)
628     end
629   end
630
631   def pick_card(user)
632     p = get_player(user)
633     announce _("%{player} picks a card") % { :player => p }
634     deal(p, 1)
635     @player_has_picked = true
636   end
637
638   def deal(player, num=1)
639     picked = []
640     num.times do
641       picked << @stock.delete_one
642       if @stock.length == 0
643         announce _("Shuffling discarded cards")
644         make_stock
645         if @stock.length == 0
646           announce _("No more cards!")
647           end_game # FIXME nope!
648         end
649       end
650     end
651     picked.sort!
652     notify player, _("You picked %{picked}") % { :picked => picked.join(' ') }
653     player.cards += picked
654     player.cards.sort!
655   end
656
657   def add_player(user)
658     if p = get_player(user)
659       announce _("you're already in the game, %{p}") % {
660         :p => p
661       }
662       return
663     end
664     @dropouts.each do |dp|
665       if dp.user == user
666         announce _("you dropped from the game, %{p}, you can't get back in") % {
667           :p => dp
668         }
669         return
670       end
671     end
672     if @w4_played_flag == 1
673       announce _("you cannot join until after the Wild +4 is resolved, %{p}") % {
674         :p => user
675       }
676       return
677     end
678     cards = 7
679     if @start_time
680       cards = (@players.inject(0) do |s, pl|
681         s +=pl.cards.length
682       end*1.0/@players.length).ceil
683     end
684     p = Player.new(user)
685     @players << p
686     announce _("%{p} joins this game of %{uno}") % {
687       :p => p, :uno => UNO
688     }
689     deal(p, cards)
690     return if @start_time
691     if @join_timer
692       @bot.timer.reschedule(@join_timer, 10)
693     elsif @players.length > 1
694       announce _("game will start in 20 seconds")
695       @join_timer = @bot.timer.add_once(20) {
696         start_game
697       }
698     end
699   end
700
701   def drop_player(nick)
702     # A nick is passed because the original player might have left
703     # the channel or IRC
704     unless p = get_player(nick)
705       announce _("%{p} isn't playing %{uno}") % {
706         :p => p, :uno => UNO
707       }
708       return
709     end
710     announce _("%{p} gives up this game of %{uno}") % {
711       :p => p, :uno => UNO
712     }
713     case @players.length
714     when 2
715       if p == @players.first
716         next_turn
717       end
718       end_game
719       return
720     when 1
721       end_game(true)
722       return
723     end
724     debug @stock.length
725     while p.cards.length > 0
726       @stock.insert(rand(@stock.length), p.cards.shift)
727     end
728     debug @stock.length
729     @dropouts << @players.delete_one(p)
730   end
731
732   def replace_player(old, new)
733     # The new user
734     user = channel.get_user(new)
735     if p = get_player(user)
736       announce _("%{p} is already playing %{uno} here") % {
737         :p => p, :uno => UNO
738       }
739       return
740     end
741     # We scan the player list of the player with the old nick, instead
742     # of using get_player, in case of IRC drops etc
743     @players.each do |p|
744       if p.user.nick == old
745         p.user = user
746         announce _("%{p} takes %{b}%{old}%{b}'s place at %{uno}") % {
747           :p => p, :b => Bold, :old => old, :uno => UNO
748         }
749         return
750       end
751     end
752     announce _("%{b}%{old}%{b} isn't playing %{uno} here") % {
753       :uno => UNO, :b => Bold, :old => old
754     }
755   end
756
757   def end_game(halted = false)
758     runtime = @start_time ? Time.now -  @start_time : 0
759     if halted
760       if @start_time
761         announce _("%{uno} game halted after %{time}") % {
762           :time => elapsed_time,
763           :uno => UNO
764         }
765       else
766         announce _("%{uno} game halted before it could start") % {
767           :uno => UNO
768         }
769       end
770     else
771       announce _("%{uno} game finished after %{time}! The winner is %{p}") % {
772         :time => elapsed_time,
773         :uno => UNO, :p => @players.first
774       }
775     end
776     if @picker > 0 and not halted
777       p = @players[1]
778       announce _("%{p} has to pick %{b}%{n}%{b} cards!") % {
779         :p => p, :n => @picker, :b => Bold
780       }
781       deal(p, @picker)
782       @picker = 0
783     end
784     score = @players.inject(0) do |sum, p|
785       if p.cards.length > 0
786         announce _("%{p} still had %{cards}") % {
787           :p => p, :cards => p.cards.join(' ')
788         }
789         sum += p.cards.inject(0) do |cs, c|
790           cs += c.score
791         end
792       end
793       sum
794     end
795
796     closure = { :dropouts => @dropouts, :players => @players, :runtime => runtime }
797     if not halted
798       announce _("%{p} wins with %{b}%{score}%{b} points!") % {
799         :p => @players.first, :score => score, :b => Bold
800       }
801       closure.merge!(:winner => @players.first, :score => score,
802         :opponents => @players.length - 1)
803     end
804
805     @plugin.do_end_game(@channel, closure)
806   end
807
808 end
809
810 # A won game: store score and number of opponents, so we can calculate
811 # an average score per opponent (requested by Squiddhartha)
812 define_structure :UnoGameWon, :score, :opponents
813 # For each player we store the number of games played, the number of
814 # games forfeited, and an UnoGameWon for each won game
815 define_structure :UnoPlayerStats, :played, :forfeits, :won
816
817 class UnoPlugin < Plugin
818   attr :games
819   def initialize
820     super
821     @games = {}
822   end
823
824   def help(plugin, topic="")
825     case topic
826     when 'commands'
827       [
828       _("'jo' to join in"),
829       _("'pl <card>' to play <card>: e.g. 'pl g7' to play Green 7, or 'pl rr' to play Red Reverse, or 'pl y2y2' to play both Yellow 2 cards"),
830       _("'pe' to pick a card"),
831       _("'pa' to pass your turn"),
832       _("'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)"),
833       _("'ca' to show current cards"),
834       _("'cd' to show the current discard"),
835       _("'ch' to challenge a Wild +4"),
836       _("'od' to show the playing order"),
837       _("'ti' to show play time"),
838       _("'tu' to show whose turn it is")
839     ].join("; ")
840     when 'challenge'
841       _("A Wild +4 can only be played legally if you don't have normal (not special) cards of the current color. ") +
842       _("The next player can challenge a W+4 by using the 'ch' command. ") +
843       _("If the W+4 play was illegal, the player who played it must pick the W+4, pick 4 cards from the stock, and play a legal card. ") +
844       _("If the W+4 play was legal, the challenger must pick 6 cards instead of 4.")
845     when 'rules'
846       _("play all your cards, one at a time, by matching either the color or the value of the currently discarded card. ") +
847       _("cards with special effects: Skip (next player skips a turn), Reverse (reverses the playing order), +2 (next player has to take 2 cards). ") +
848       _("Wilds can be played on any card, and you must specify the color for the next card. ") +
849       _("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. ") +
850       _("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. ") +
851       _("you can also play a Reverse on a +2 or +4, bouncing the effect back to the previous player (that now comes next). ")
852     when /scor(?:e|ing)/, /points?/
853       [
854       _("The points won with a game of %{uno} are totalled from the cards remaining in the hands of the other players."),
855       _("Each normal (not special) card is worth its face value (from 0 to 9 points)."),
856       _("Each colored special card (+2, Reverse, Skip) is worth 20 points."),
857       _("Each Wild and Wild +4 is worth 50 points.")
858       ].join(" ") % { :uno => UnoGame::UNO }
859     when /cards?/
860       [
861       _("There are 108 cards in a standard %{uno} deck."),
862       _("For each color (Blue, Green, Red, Yellow) there are 19 numbered cards (from 0 to 9), with two of each number except for 0."),
863       _("There are also 6 special cards for each color, two each of +2, Reverse, Skip."),
864       _("Finally, there are 4 Wild and 4 Wild +4 cards.")
865       ].join(" ") % { :uno => UnoGame::UNO }
866     when 'admin'
867       _("The game manager (the user that started the game) can execute the following commands to manage it: ") +
868       [
869       _("'uno drop <user>' to drop a user from the game (any user can drop itself using 'uno drop')"),
870       _("'uno replace <old> [with] <new>' to replace a player with someone else (useful in case of disconnects)"),
871       _("'uno transfer [to] <nick>' to transfer game ownership to someone else"),
872       _("'uno end' to end the game before its natural completion")
873       ].join("; ")
874     else
875       _("%{uno} game. !uno to start a game. see 'help uno rules' for the rules, 'help uno admin' for admin commands. In-game commands: %{cmds}.") % {
876         :uno => UnoGame::UNO,
877         :cmds => help(plugin, 'commands')
878       }
879     end
880   end
881
882   def message(m)
883     return unless @games.key?(m.channel)
884     return unless m.plugin # skip messages such as: <someuser> botname,
885     g = @games[m.channel]
886     case m.plugin.intern
887     when :jo # join game
888       return if m.params
889       g.add_player(m.source)
890     when :pe # pick card
891       return if m.params
892       if g.has_turn?(m.source)
893         if g.player_has_picked
894           m.reply _("you already picked a card")
895         elsif g.picker > 0
896           g.pass(m.source)
897         else
898           g.pick_card(m.source)
899         end
900       else
901         m.reply _("It's not your turn")
902       end
903     when :pa # pass turn
904       return if m.params or not g.start_time
905       if g.has_turn?(m.source)
906         g.pass(m.source)
907       else
908         m.reply _("It's not your turn")
909       end
910     when :pl # play card
911       if g.has_turn?(m.source)
912         g.play_card(m.source, m.params.downcase)
913       else
914         m.reply _("It's not your turn")
915       end
916     when :co # pick color
917       if g.has_turn?(m.source)
918         g.choose_color(m.source, m.params.downcase)
919       else
920         m.reply _("It's not your turn")
921       end
922     when :ca # show current cards
923       return if m.params
924       g.show_all_cards(m.source)
925     when :cd # show current discard
926       return if m.params or not g.start_time
927       g.show_discard
928     when :ch
929       if g.has_turn?(m.source)
930         if g.last_discard && (g.w4_played_flag == 1)
931           g.challenge
932         else
933           m.reply _("previous move cannot be challenged")
934         end
935       else
936         m.reply _("It's not your turn")
937       end
938     when :od # show playing order
939       return if m.params
940       g.show_order
941     when :ti # show play time
942       return if m.params
943       g.show_time
944     when :tu # show whose turn is it
945       return if m.params
946       if g.has_turn?(m.source)
947         m.nickreply _("it's your turn, sleepyhead")
948       else
949         g.show_turn(:cards => false)
950       end
951     end
952   end
953
954   def create_game(m, p)
955     if @games.key?(m.channel)
956       m.reply _("There is already an %{uno} game running here, managed by %{who}. say 'jo' to join in") % {
957         :who => @games[m.channel].manager,
958         :uno => UnoGame::UNO
959       }
960       return
961     end
962     @games[m.channel] = UnoGame.new(self, m.channel, m.source)
963     @bot.auth.irc_to_botuser(m.source).set_temp_permission('uno::manage', true, m.channel)
964     m.reply _("Ok, created %{uno} game on %{channel}, say 'jo' to join in") % {
965       :uno => UnoGame::UNO,
966       :channel => m.channel
967     }
968   end
969
970   def transfer_ownership(m, p)
971     unless @games.key?(m.channel)
972       m.reply _("There is no %{uno} game running here") % { :uno => UnoGame::UNO }
973       return
974     end
975     g = @games[m.channel]
976     old = g.manager
977     new = m.channel.get_user(p[:nick])
978     if new
979       g.manager = new
980       @bot.auth.irc_to_botuser(old).reset_temp_permission('uno::manage', m.channel)
981       @bot.auth.irc_to_botuser(new).set_temp_permission('uno::manage', true, m.channel)
982       m.reply _("%{uno} game ownership transferred from %{old} to %{nick}") % {
983         :uno => UnoGame::UNO, :old => old, :nick => p[:nick]
984       }
985     else
986       m.reply _("who is this %{nick} you want me to transfer game ownership to?") % p
987     end
988   end
989
990   def end_game(m, p)
991     unless @games.key?(m.channel)
992       m.reply _("There is no %{uno} game running here") % { :uno => UnoGame::UNO }
993       return
994     end
995     @games[m.channel].end_game(true)
996   end
997
998   def cleanup
999     @games.each { |k, g| g.end_game(true) }
1000     super
1001   end
1002
1003   def chan_reg(channel)
1004     @registry.sub_registry(channel.downcase)
1005   end
1006
1007   def chan_stats(channel)
1008     stats = chan_reg(channel).sub_registry('stats')
1009     class << stats
1010       def store(val)
1011         val.to_i
1012       end
1013       def restore(val)
1014         val.to_i
1015       end
1016     end
1017     stats.set_default(0)
1018     return stats
1019   end
1020
1021   def chan_pstats(channel)
1022     pstats = chan_reg(channel).sub_registry('players')
1023     pstats.set_default(UnoPlayerStats.new(0,0,[]))
1024     return pstats
1025   end
1026
1027   def do_end_game(channel, closure)
1028     reg = chan_reg(channel)
1029     stats = chan_stats(channel)
1030     stats['played'] += 1
1031     stats['played_runtime'] += closure[:runtime]
1032     if closure[:winner]
1033       stats['finished'] += 1
1034       stats['finished_runtime'] += closure[:runtime]
1035
1036       pstats = chan_pstats(channel)
1037
1038       closure[:players].each do |pl|
1039         k = pl.user.downcase
1040         pls = pstats[k]
1041         pls.played += 1
1042         pstats[k] = pls
1043       end
1044
1045       closure[:dropouts].each do |pl|
1046         k = pl.user.downcase
1047         pls = pstats[k]
1048         pls.played += 1
1049         pls.forfeits += 1
1050         pstats[k] = pls
1051       end
1052
1053       winner = closure[:winner]
1054       won = UnoGameWon.new(closure[:score], closure[:opponents])
1055       k = winner.user.downcase
1056       pls = pstats[k] # already marked played +1 above
1057       pls.won << won
1058       pstats[k] = pls
1059     end
1060
1061     @bot.auth.irc_to_botuser(@games[channel].manager).reset_temp_permission('uno::manage', channel)
1062     @games.delete(channel)
1063   end
1064
1065   def do_chanstats(m, p)
1066     stats = chan_stats(m.channel)
1067     np = stats['played']
1068     nf = stats['finished']
1069     if np > 0
1070       str = _("%{nf} %{uno} games completed over %{np} games played. ") % {
1071         :np => np, :uno => UnoGame::UNO, :nf => nf
1072       }
1073       cgt = stats['finished_runtime']
1074       tgt = stats['played_runtime']
1075       str << _("%{cgt} game time for completed games") % {
1076         :cgt => Utils.secs_to_string(cgt)
1077       }
1078       if np > nf
1079         str << _(" on %{tgt} total game time. ") % {
1080           :tgt => Utils.secs_to_string(tgt)
1081         }
1082       else
1083         str << ". "
1084       end
1085       str << _("%{avg} average game time for completed games") % {
1086         :avg => Utils.secs_to_string(cgt/nf)
1087       }
1088       str << _(", %{tavg} for all games") % {
1089         :tavg => Utils.secs_to_string(tgt/np)
1090       } if np > nf
1091       m.reply str
1092     else
1093       m.reply _("nobody has played %{uno} on %{chan} yet") % {
1094         :uno => UnoGame::UNO, :chan => m.channel
1095       }
1096     end
1097   end
1098
1099   def do_pstats(m, p)
1100     dnick = p[:nick] || m.source # display-nick, don't later case
1101     nick = dnick.downcase
1102     ps = chan_pstats(m.channel)[nick]
1103     if ps.played == 0
1104       m.reply _("%{nick} never played %{uno} here") % {
1105         :uno => UnoGame::UNO, :nick => dnick
1106       }
1107       return
1108     end
1109     np = ps.played
1110     nf = ps.forfeits
1111     nfullgames = np - nf
1112     nw = ps.won.length
1113     score = ps.won.inject(0) { |sum, w| sum += w.score }
1114     str = _("%{nick} played %{np} %{uno} games here, ") % {
1115       :nick => dnick, :np => np, :uno => UnoGame::UNO
1116     }
1117     str << _("forfeited %{nf} games, ") % { :nf => nf } if nf > 0
1118     str << _("won %{nw} games") % { :nw => nw}
1119     if nw > 0
1120       str << _(" with %{score} total points,") % { :score => score }
1121       avg = Float(ps.won.inject(0) { |sum, w| sum += w.score/w.opponents })/Float(nw)
1122         avgrounded = (avg*100).round/100.0
1123       str << _(" an average of %{avgrounded} points per opponent,") % { :avgrounded => avgrounded }
1124         avgpergame = 0.0
1125         avgpergamerounded = 0.0
1126         if nfullgames > 0
1127           avgpergame = Float(score)/Float(nfullgames)
1128           avgpergamerounded = (avgpergame*100).round/100.0
1129         end
1130       str << _(" and an average of %{avgpergamerounded} points per game") % { :avgpergamerounded => avgpergamerounded }
1131     end
1132     m.reply str
1133   end
1134
1135   def replace_player(m, p)
1136     unless @games.key?(m.channel)
1137       m.reply _("There is no %{uno} game running here") % { :uno => UnoGame::UNO }
1138       return
1139     end
1140     @games[m.channel].replace_player(p[:old], p[:new])
1141   end
1142
1143   def drop_player(m, p)
1144     unless @games.key?(m.channel)
1145       m.reply _("There is no %{uno} game running here") % { :uno => UnoGame::UNO }
1146       return
1147     end
1148     @games[m.channel].drop_player(p[:nick] || m.source.nick)
1149   end
1150
1151   def print_stock(m, p)
1152     unless @games.key?(m.channel)
1153       m.reply _("There is no %{uno} game running here") % { :uno => UnoGame::UNO }
1154       return
1155     end
1156     stock = @games[m.channel].stock
1157     m.reply(_("%{num} cards in stock: %{stock}") % {
1158       :num => stock.length,
1159       :stock => stock.join(' ')
1160     }, :split_at => /#{NormalText}\s*/)
1161   end
1162
1163   def do_top(m, p)
1164     pstats = chan_pstats(m.channel)
1165     scores = []
1166     wins = []
1167     avgspergame = []
1168     statshash = Hash.new
1169     pstats.each do |k, v|
1170       wins << [v.won.length, k]
1171           np = v.played
1172       nf = v.forfeits
1173       nfullgames = np - nf
1174           score = v.won.inject(0) { |sum, w| sum += w.score }
1175           avgpergame = 0.0
1176           avgpergamerounded = 0.0
1177           if nfullgames > 0
1178             avgpergame = Float(score)/Float(nfullgames)
1179             avgpergamerounded = (avgpergame*100).round/100.0
1180       end
1181           if p[:mingames] != nil
1182             if nfullgames >= p[:mingames].to_i
1183           avgspergame << [avgpergamerounded, k]
1184             end
1185           else
1186             avgspergame << [avgpergamerounded, k]
1187           end
1188           scores << [score, k]
1189           statshash[k] = [v.won.length, score, nfullgames, avgpergamerounded]
1190     end
1191
1192     if n = p[:scorenum]
1193       msg = _("%{uno} %{num} highest scores: ") % {
1194         :uno => UnoGame::UNO, :num => p[:scorenum]
1195       }
1196       scores.sort! { |a1, a2| -(a1.first <=> a2.first) }
1197       scores = scores[0, n.to_i].compact
1198       i = 0
1199       if scores.length <= 5
1200         list = "\n" + scores.map { |a|
1201           i+=1
1202           _("%{i}. %{b}%{nick}%{b} with %{b}%{score}%{b} points (%{gms} games, %{ppg} PPG)") % {
1203             :i => i, :b => Bold, :nick => a.last, :score => a.first, :gms => statshash[a.last][2], :ppg => statshash[a.last][3]
1204           }
1205         }.join("\n")
1206       else
1207         list = scores.map { |a|
1208           i+=1
1209           _("%{i}. %{nick} ( %{score} )") % {
1210             :i => i, :nick => a.last, :score => a.first
1211           }
1212         }.join(" | ")
1213       end
1214     elsif n = p[:winnum]
1215       msg = _("%{uno} %{num} most wins: ") % {
1216         :uno => UnoGame::UNO, :num => p[:winnum]
1217       }
1218       wins.sort! { |a1, a2| -(a1.first <=> a2.first) }
1219       wins = wins[0, n.to_i].compact
1220       i = 0
1221       if wins.length <= 5
1222         list = "\n" + wins.map { |a|
1223           i+=1
1224           _("%{i}. %{b}%{nick}%{b} with %{b}%{score}%{b} wins (%{gms} games, %{ppg} PPG)") % {
1225             :i => i, :b => Bold, :nick => a.last, :score => a.first, :gms => statshash[a.last][2], :ppg => statshash[a.last][3]
1226           }
1227         }.join("\n")
1228       else
1229         list = wins.map { |a|
1230           i+=1
1231           _("%{i}. %{nick} ( %{score} )") % {
1232             :i => i, :nick => a.last, :score => a.first
1233           }
1234         }.join(" | ")
1235       end
1236     elsif n = p[:avgnum]
1237       msg = _("%{uno} %{num} best average points per game (minimum number of games = %{mingms}): ") % {
1238         :uno => UnoGame::UNO, :num => p[:avgnum], :mingms => p[:mingames]
1239       }
1240       avgspergame.sort! { |a1, a2| -(a1.first <=> a2.first) }
1241       avgspergame = avgspergame[0, n.to_i].compact
1242       i = 0
1243       if avgspergame.length <= 5
1244         list = "\n" + avgspergame.map { |a|
1245           i+=1
1246           _("%{i}. %{b}%{nick}%{b} with %{b}%{avg}%{b} points per game (%{gms} games, %{pts} points)") % {
1247             :i => i, :b => Bold, :nick => a.last, :avg => a.first, :gms => statshash[a.last][2], :pts => statshash[a.last][1]
1248           }
1249         }.join("\n")
1250       else
1251         list = avgspergame.map { |a|
1252           i+=1
1253           _("%{i}. %{nick} ( %{avg} )") % {
1254             :i => i, :nick => a.last, :avg => a.first
1255           }
1256         }.join(" | ")
1257       end
1258
1259     else
1260       msg = _("uh, what kind of score list did you want, again?")
1261       list = _(" I can only show the top scores (with top), the most wins (with topwin), and the best average points per game (with topavg)")
1262     end
1263     m.reply msg + list, :max_lines => (msg+list).count("\n")+1
1264   end
1265 end
1266
1267 pg = UnoPlugin.new
1268
1269 pg.map 'uno', :private => false, :action => :create_game
1270 pg.map 'uno end', :private => false, :action => :end_game, :auth_path => 'manage'
1271 pg.map 'uno drop', :private => false, :action => :drop_player, :auth_path => 'manage::drop::self!'
1272 pg.map 'uno giveup', :private => false, :action => :drop_player, :auth_path => 'manage::drop::self!'
1273 pg.map 'uno drop :nick', :private => false, :action => :drop_player, :auth_path => 'manage::drop::other!'
1274 pg.map 'uno replace :old [with] :new', :private => false, :action => :replace_player, :auth_path => 'manage'
1275 pg.map 'uno transfer [game [ownership]] [to] :nick', :private => false, :action => :transfer_ownership, :auth_path => 'manage'
1276 pg.map 'uno stock', :private => false, :action => :print_stock
1277 pg.map 'uno chanstats', :private => false, :action => :do_chanstats
1278 pg.map 'uno stats [:nick]', :private => false, :action => :do_pstats
1279 pg.map 'uno top :scorenum', :private => false, :action => :do_top, :defaults => { :scorenum => 5 }
1280 pg.map 'uno topwin :winnum', :private => false, :action => :do_top, :defaults => { :winnum => 5 }
1281 pg.map 'uno topavg :avgnum :mingames', :private => false, :action => :do_top, :defaults => { :avgnum => 5, :mingames => 1 }
1282
1283 pg.default_auth('stock', false)
1284 pg.default_auth('manage', false)
1285 pg.default_auth('manage::drop::self', true)