dblock/slack-gamebot

View on GitHub
slack-gamebot/models/challenge.rb

Summary

Maintainability
C
1 day
Test Coverage
class Challenge
  include Mongoid::Document
  include Mongoid::Timestamps

  index(state: 1, channel: 1)

  SORT_ORDERS = ['created_at', '-created_at', 'updated_at', '-updated_at', 'state', '-state', 'channel', '-channel'].freeze

  field :state, type: String, default: ChallengeState::PROPOSED
  field :channel, type: String

  belongs_to :team, index: true
  belongs_to :season, inverse_of: :challenges, index: true, optional: true
  belongs_to :created_by, class_name: 'User', inverse_of: nil, index: true, optional: true
  belongs_to :updated_by, class_name: 'User', inverse_of: nil, index: true, optional: true

  has_and_belongs_to_many :challengers, class_name: 'User', inverse_of: nil
  has_and_belongs_to_many :challenged, class_name: 'User', inverse_of: nil

  field :draw_scores, type: Array
  has_and_belongs_to_many :draw, class_name: 'User', inverse_of: nil

  has_one :match

  index({ challenger_ids: 1, state: 1 }, name: 'active_challenger_index')
  index({ challenged_ids: 1, state: 1 }, name: 'active_challenged_index')

  validate :validate_playing_against_themselves
  validate :validate_opponents_counts
  validate :validate_unique_challenge
  validate :validate_teams

  validate :validate_updated_by
  validate :validate_draw_scores
  validates_presence_of :updated_by, if: ->(challenge) { challenge.state != ChallengeState::PROPOSED }
  validates_presence_of :team

  # current challenges are not in an archived season
  scope :current, -> { where(season_id: nil) }

  # challenges scoped by state
  ChallengeState.values.each do |state|
    scope state.to_sym, -> { where(state: state) }
  end

  # Given a challenger and a list of names splits into two groups, returns users.
  def self.split_teammates_and_opponents(client, challenger, names, separator = 'with')
    teammates = [challenger]
    opponents = []
    current_side = opponents

    names.each do |name|
      if name == separator
        current_side = teammates
      else
        current_side << ::User.find_by_slack_mention!(client, name)
      end
    end

    [teammates, opponents]
  end

  def self.new_from_teammates_and_opponents(client, channel, challenger, names, separator = 'with')
    teammates, opponents = split_teammates_and_opponents(client, challenger, names, separator)
    Challenge.new(
      team: client.owner,
      channel: channel,
      created_by: challenger,
      challengers: teammates,
      challenged: opponents,
      state: ChallengeState::PROPOSED
    )
  end

  def self.create_from_teammates_and_opponents!(client, channel, challenger, names, separator = 'with')
    new_from_teammates_and_opponents(client, channel, challenger, names, separator).tap(&:save!)
  end

  def accept!(challenger)
    raise SlackGamebot::Error, "Challenge has already been #{state}." unless state == ChallengeState::PROPOSED

    updates = { updated_by: challenger, state: ChallengeState::ACCEPTED }
    updates[:challenged_ids] = [challenger._id] if open_challenge?
    update_attributes!(updates)
  end

  def decline!(challenger)
    raise SlackGamebot::Error, "Challenge has already been #{state}." unless state == ChallengeState::PROPOSED

    update_attributes!(updated_by: challenger, state: ChallengeState::DECLINED)
  end

  def cancel!(challenger)
    raise SlackGamebot::Error, "Challenge has already been #{state}." unless [ChallengeState::PROPOSED, ChallengeState::ACCEPTED].include?(state)

    update_attributes!(updated_by: challenger, state: ChallengeState::CANCELED)
  end

  def lose!(loser, scores = nil)
    raise SlackGamebot::Error, 'Challenge must first be accepted.' if state == ChallengeState::PROPOSED
    raise SlackGamebot::Error, "Challenge has already been #{state}." unless state == ChallengeState::ACCEPTED

    winners, losers = winners_and_losers_for(loser)
    Match.lose!(team: team, challenge: self, winners: winners, losers: losers, scores: scores)
    update_attributes!(state: ChallengeState::PLAYED)
  end

  def winners_and_losers_for(loser)
    if challenged.include?(loser)
      [challengers, challenged]
    elsif challengers.include?(loser)
      [challenged, challengers]
    else
      raise SlackGamebot::Error, "Only #{(challenged + challengers).map(&:user_name).or} can lose this challenge."
    end
  end

  def resign!(loser, scores = nil)
    raise SlackGamebot::Error, 'Challenge must first be accepted.' if state == ChallengeState::PROPOSED
    raise SlackGamebot::Error, "Challenge has already been #{state}." unless state == ChallengeState::ACCEPTED

    winners, losers = winners_and_losers_for_resigned(loser)
    Match.resign!(team: team, challenge: self, winners: winners, losers: losers, scores: scores)
    update_attributes!(state: ChallengeState::PLAYED)
  end

  def winners_and_losers_for_resigned(loser)
    if challenged.include?(loser)
      [challengers, challenged]
    elsif challengers.include?(loser)
      [challenged, challengers]
    else
      raise SlackGamebot::Error, "Only #{(challenged + challengers).map(&:user_name).or} can resign this challenge."
    end
  end

  def draw!(player, scores = nil)
    raise SlackGamebot::Error, 'Challenge must first be accepted.' if state == ChallengeState::PROPOSED
    raise SlackGamebot::Error, "Challenge has already been #{state}." unless state == ChallengeState::ACCEPTED || state == ChallengeState::DRAWN
    raise SlackGamebot::Error, "Already recorded a draw from #{player.user_name}." if draw.include?(player)

    draw << player
    update_attributes!(state: ChallengeState::DRAWN)
    update_attributes!(draw_scores: scores) if scores
    return if draw.count != (challenged.count + challengers.count)

    # in a draw, winners have a lower original elo
    winners, losers = winners_and_losers_for_draw(player)
    Match.draw!(team: team, challenge: self, winners: winners, losers: losers, scores: scores)
    update_attributes!(state: ChallengeState::PLAYED)
  end

  def winners_and_losers_for_draw(player)
    raise SlackGamebot::Error, "Only #{(challenged + challengers).map(&:user_name).or} can draw this challenge." unless challenged.include?(player) || challengers.include?(player)

    if Elo.team_elo(challenged) < Elo.team_elo(challengers)
      [challenged, challengers]
    else
      [challengers, challenged]
    end
  end

  def to_s
    "a challenge between #{challengers.map(&:display_name).and} and #{challenged.map(&:display_name).and}"
  end

  def self.find_by_user(team, channel, player, states = [ChallengeState::PROPOSED, ChallengeState::ACCEPTED])
    Challenge.any_of(
      { challenger_ids: player._id },
      challenged_ids: player._id
    ).where(
      team: team,
      channel: channel,
      :state.in => states
    ).first
  end

  def self.find_open_challenge(team, channel, states = [ChallengeState::PROPOSED])
    Challenge.where(
      team: team,
      challenged_ids: team.users.everyone.map(&:_id),
      channel: channel,
      :state.in => states
    ).first
  end

  def open_challenge?
    challenged.any?(&:anyone?)
  end

  def draw_scores?
    draw_scores&.any?
  end

  private

  def validate_playing_against_themselves
    intersection = challengers & challenged
    errors.add(:challengers, "Player #{intersection.first.user_name} cannot play against themselves.") if intersection.any?
  end

  def validate_opponents_counts
    return if challengers.any? && challenged.any? && (challengers.count == challenged.count || team.unbalanced)

    errors.add(:challenged, "Number of teammates (#{challengers.count}) and opponents (#{challenged.count}) must match.")
  end

  def validate_teams
    teams = [team]
    teams.concat(challengers.map(&:team))
    teams.concat(challenged.map(&:team))
    teams << match.team if match
    teams << season.team if season
    teams.uniq!
    errors.add(:team, 'Can only play others on the same team.') if teams.count != 1
  end

  def validate_unique_challenge
    return unless state == ChallengeState::PROPOSED || state == ChallengeState::ACCEPTED

    (challengers + challenged).each do |player|
      existing_challenge = ::Challenge.find_by_user(team, channel, player)
      next unless existing_challenge.present?
      next if existing_challenge == self

      errors.add(:challenge, "#{player.user_name} can't play. There's already #{existing_challenge}.")
    end
  end

  def validate_updated_by
    case state
    when ChallengeState::ACCEPTED
      return if updated_by && challenged.include?(updated_by)

      errors.add(:accepted_by, "Only #{challenged.map(&:display_name).and} can accept this challenge.")
    when ChallengeState::DECLINED
      return if updated_by && challenged.include?(updated_by)

      errors.add(:declined_by, "Only #{challenged.map(&:display_name).and} can decline this challenge.")
    when ChallengeState::CANCELED
      return if updated_by && (challengers.include?(updated_by) || challenged.include?(updated_by))

      errors.add(:declined_by, "Only #{challengers.map(&:display_name).and} or #{challenged.map(&:display_name).and} can cancel this challenge.")
    end
  end

  def validate_draw_scores
    return unless draw_scores
    return if Score.tie?(draw_scores)

    errors.add(:scores, 'In a tie both sides must score the same number of points.')
  end
end