#!/usr/bin/ruby -w
#
# McMonopoly
#
# Model of Hasbro and McDonalds' $1 Monopoly game
#
# Author: Dave Burt
# Created: 13 June 2009
# Last modified: 16 June 2009
#

#TODO: add detailed reporting, board drawing

module McMonopoly

  class GameEvent < RuntimeError; end
  class OutOfMoney < GameEvent; end
  class GameOver < GameEvent; end

  class Player

    attr_reader :board
    attr_reader :money
    attr_reader :name

    def initialize board, money, name=nil
      raise ArgumentError, "money must be positive" unless money > 0
      (@@players ||= []) << self
      @board = board
      @money = money
      @name = name || "Player #{@@players.size}"
    end

    def to_s
      name
    end

    def << n
      @money += n
      board.message "#{self} gets #{n}."
      raise OutOfMoney, "#{name} is out of money" if money <= 0
    end

    def pay other, amount
      raise ArgumentError, "amount must be positive" unless amount > 0
      other << [money, amount].min
      self << -[money, amount].min
    end

  end

  class Board

    GO_VALUE = 2

    attr_reader :squares

    def initialize number_of_players

      @squares = [
        Square.new("Go (#{GO_VALUE})"),
        Property.new("Purple", 1),
        Property.new("Purple", 1),
        SpinAgain.new,
        Bonus.new(2),
        Property.new("Light Blue", 1),
        Property.new("Light Blue", 1),
        Bonus.new(-2),
        Square.new("Lounge"),
        Property.new("Pink", 2),
        Property.new("Pink", 2),
        SpinAgain.new,
        Property.new("Orange", 2),
        Property.new("Orange", 2),
        MoneyPile.new,
        Property.new("Red", 3),
        Property.new("Red", 3),
        SpinAgain.new,
        Bonus.new(2),
        Property.new("Yellow", 3),
        Property.new("Yellow", 3),
        Bonus.new(-2),
        McDonalds.new,
        Property.new("Green", 4),
        Property.new("Green", 4),
        SpinAgain.new,
        Property.new("Blue", 5),
        Property.new("Blue", 5),
      ]
      squares.each {|square| square.board = self }

      number_of_players.times do |i|
        squares[0].add_occupier Player.new(self, 18, "Player #{i+1}")
      end

      @callback = proc {|args| yield args } if block_given?
    end

    def [](obj)
      case obj
      when Player
        squares.detect {|square| square.occupiers.include? obj }
      when String
        squares.detect {|square| square.name == obj }
      when Integer
        squares[obj % squares.size]
      else
        raise TypeError,
          "couldn't convert #{obj.class} to Player, String or Integer"
      end
    end

    def players
      squares.map {|square| square.occupiers }.flatten
    end

    def to_s
      squares.join("\n")
    end

    def move player
      # Pick up token
      starting_square = self[player]
      starting_square.occupiers.delete player

      # Spin spinner
      spin = Spinner.spin
      n = squares.index(starting_square) + spin
      message "#{player} spins #{spin}."

      # Get paid if passing go
      if n >= squares.size
        player << GO_VALUE 
        message "#{player} passes go and collects #{GO_VALUE}."
      end

      # Land token
      begin

        message "#{player} lands on #{self[n]}."
        self[n].add_occupier player

      rescue OutOfMoney => err
        winner = players.sort_by {|player| player.money.to_i }.last
        raise GameOver,
          "#{err.message}; #{winner} wins the game with #{winner.money}"
      end
    end

    def play_turn
      current_player = players.shift
      players << current_player

      move current_player
    end

    def play_game
      play_turn while true
    end

    def message msg
      @callback.call msg if @callback
    end
  end

  class Spinner
    def self.spin
      rand(6) + 1
    end
  end

  class Square
    attr_accessor :board
    attr_reader :occupiers
    attr_reader :name
    def initialize name
      @name = name
      @occupiers = []
    end
    def add_occupier player
      @occupiers << player
    end
    def to_s
      "#{name} [#{occupiers.join(', ')}]"
    end
    def inspect
      "#<Square #{to_s}>"
    end
  end

  class SpinAgain < Square
    def initialize
      super "Spin Again"
    end
    def add_occupier player
      super
      board.move player
    end
  end

  class Bonus < Square
    attr_reader :amount
    def initialize amount, name=nil
      super name || ("%+d" % amount)
      @amount = amount
    end
    def add_occupier player
      super
      if player
        player << amount
        board["Money Pile"] << amount.abs if amount < 0
      end
    end
  end

  class MoneyPile < Bonus
    def initialize
      super 0, "Money Pile"
    end
    def to_s
      "#{name} (#{amount}) [#{occupiers.join(', ')}]"
    end
    def add_occupier player
      super
      @amount = 0
    end
    def << money
      @amount += money
    end
  end

  class McDonalds < Bonus
    def initialize
      super(-3, "McDonald's")
    end
    def add_occupier player
      super
      @occupiers.delete player
      board["Lounge"].add_occupier player
    end
  end

  class Property < Square
    attr_reader :color
    attr_reader :owner
    attr_reader :price
    def initialize color, price
      super "#{color}"
      @color = color
      @price = price
    end
    def to_s
      "#{name} (#{price}) #{owner} [#{occupiers.join(', ')}]"
    end
    def add_occupier player
      super
      case owner
      when nil
        board.message "#{player} buys #{name}."
        player << -price
        @owner = player
      when player
        nil
      else
        double = double_rent?
        board.message "Double rent!" if double
        player.pay owner, price * (double ? 2 : 1)
      end
    end
    def double_rent?
      board.squares.select do |square|
        square.kind_of?(Property) && square.color == color
      end.all? do |property|
        property.owner == owner
      end
    end
  end

end

if $0 == __FILE__

  require 'optparse'

  opts = {
    :players => 3,
    :games => 1
  }

  op = OptionParser.new do |op|
    op.banner = "usage: #$0 [options]"
    op.on("-p", "--players N",Integer,"number of players") {|p|opts[:players]=p}
    op.on("-g", "--games N", Integer, "number of games") {|g| opts[:games] = g }
    op.on("-f", "--file F", String, "output filename") {|f| opts[:file] = f }
    op.on("-c", "--csv", "output in CSV format") { opts[:format] = :csv }
    op.on("-v", "--verbose", "detailed output") { opts[:verbose] = true }
    op.on_tail("--help", "just show this message") { puts op; exit }
  end

  begin
    op.parse!(ARGV)
  rescue OptionParser::ParseError => e
    STDERR.puts "#$0: #{e}"
    STDERR.puts op
    abort
  end

  f = opts[:file] ? File.open(opts[:file], "w") : STDOUT

  f.puts "Loser,Winner,Winner Money" if opts[:format] == :csv

  opts[:games].times do

    board = McMonopoly::Board.new(opts[:players]) do |message|
      puts message if opts[:verbose]
    end

    begin

      board.play_game

    rescue McMonopoly::GameOver => game_over

      if opts[:format] == :csv
        f.puts game_over.message.scan(/\d+/).join(",")
      else
        f.puts game_over.message
      end
    end

  end

  f.close if opts[:file]

end
