diff --git a/CHANGELOG.md b/CHANGELOG.md index cc1bdf69..9a3fb3ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ### Changelog +* Added automatch support for doubles matches - [@amikula](https://github.com/amikula). * [#94](https://github.com/dblock/slack-gamebot/issues/94): De-registering and re-registering a team just reactivates the old team - [@dblock](https://github.com/dblock). * [#92](https://github.com/dblock/slack-gamebot/issues/92): Leaderboard without ranked players now says that there're no ranked players - [@dblock](https://github.com/dblock). * [#80](https://github.com/dblock/slack-gamebot/issues/80): Empty season produces `undefined method 'map' for nil:NilClass` error - [@dblock](https://github.com/dblock). diff --git a/Gemfile b/Gemfile index d3a85b6d..a99657b3 100644 --- a/Gemfile +++ b/Gemfile @@ -19,11 +19,14 @@ gem 'newrelic_rpm' gem 'newrelic-slack-ruby-bot' gem 'rack-rewrite' gem 'wannabe_bool' +gem 'chronic' +gem 'chronic_duration' group :development, :test do gem 'rake', '~> 10.4' gem 'rubocop', '0.34.2' gem 'foreman' + gem 'timecop' end group :development do diff --git a/Gemfile.lock b/Gemfile.lock index a36f1da1..45a924b0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -56,6 +56,9 @@ GEM thread_safe (~> 0.3, >= 0.3.1) bson (4.0.1) builder (3.2.2) + chronic (0.10.2) + chronic_duration (0.10.6) + numerizer (~> 0.1.1) coercible (1.0.0) descendants_tracker (~> 0.0.1) crack (0.4.3) @@ -183,6 +186,7 @@ GEM newrelic_rpm (3.14.3.313) nokogiri (1.6.7.2) mini_portile2 (~> 2.0.0.rc2) + numerizer (0.1.1) oj (2.14.4) origin (2.2.0) parser (2.3.0.5) @@ -246,6 +250,7 @@ GEM thor (0.19.1) thread_safe (0.3.5) time_ago_in_words (0.1.1) + timecop (0.8.0) tzinfo (1.2.2) thread_safe (~> 0.1) uber (0.0.15) @@ -273,6 +278,8 @@ PLATFORMS ruby DEPENDENCIES + chronic + chronic_duration database_cleaner fabrication faker @@ -299,6 +306,7 @@ DEPENDENCIES slack-ruby-bot! slack-ruby-client! time_ago_in_words + timecop unicorn vcr wannabe_bool diff --git a/README.md b/README.md index 1aff55de..14f61775 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,58 @@ gamebot cancel Victor Barna and Deng Yaping canceled a challenge against Wang Hoe and Zhang Jike. ``` +#### gamebot automatch on|off + +Turn doubles automatch on for 5 minutes, or turn it off. When 4 players have automatch on, gamebot will create a new challenge with the players with the highest and lowest ELO scores vs the players with the middle scores. + +``` +gamebot automatch on + +Automatch is on for alf (1 users ready to play!) +``` + +``` +gamebot automatch off + +Automatch is off for alf (0 users ready to play!) +``` + +#### gamebot automatch for <duration> + +Turn doubles automatch on for the specified duration. Accepts natural language duration like "5 minutes". + +``` +gamebot automatch for 5 minutes + +Automatch is on for alf (1 users ready to play!) +``` + +#### gamebot automatch until <time> + +Turn doubles automatch on until the specified time. Accepts natural language times. + +``` +gamebot automatch until 10 minutes from now + +Automatch is on for alf (1 users ready to play!) +``` + +``` +gamebot automatch until 8pm + +Automatch is on for alf (1 users ready to play!) +``` + +#### gamebot automatch + +List players with automatch on, and the durations they have remaining. + +``` +gamebot automatch + +alf for 4 mins 57 secs +``` + #### gamebot leaderboard [number|infinity] Get the leaderboard. diff --git a/slack-gamebot/commands.rb b/slack-gamebot/commands.rb index c4405b3c..2bd59776 100644 --- a/slack-gamebot/commands.rb +++ b/slack-gamebot/commands.rb @@ -1,4 +1,5 @@ require 'slack-gamebot/commands/accept' +require 'slack-gamebot/commands/automatch' require 'slack-gamebot/commands/cancel' require 'slack-gamebot/commands/challenge' require 'slack-gamebot/commands/challenges' diff --git a/slack-gamebot/commands/automatch.rb b/slack-gamebot/commands/automatch.rb new file mode 100644 index 00000000..63e4466f --- /dev/null +++ b/slack-gamebot/commands/automatch.rb @@ -0,0 +1,82 @@ +module SlackGamebot + module Commands + class Automatch < SlackRubyBot::Commands::Base + def self.call(client, data, match) + challenger = ::User.find_create_or_update_by_slack_id!(client, data.user) + + case match['expression'] + when 'on' + challenger.automatch_time = 5.minutes.from_now + challenger.save! + automatch_on(challenger, client, data) + when 'off' + challenger.automatch_time = nil + challenger.save! + automatch_off(challenger, client, data) + when /^until\b/i + parsed_time = Chronic.parse(match['expression'].sub(/^until\W*/, '')) + fail SlackGamebot::Error, "Can't understand time specified" unless parsed_time + + challenger.automatch_time = parsed_time + challenger.save! + automatch_on(challenger, client, data) + when /^for\b/i + parsed_time = ChronicDuration.parse(match['expression'].sub(/^for\W*/, '')) + fail SlackGamebot::Error, "Can't understand time specified" unless parsed_time + + challenger.automatch_time = Time.now + parsed_time + challenger.save! + automatch_on(challenger, client, data) + when nil + automatch_users = User.where(:automatch_time.gt => Time.now).order(automatch_time: :asc) + if (automatch_users.count == 0) + client.say(channel: data.channel, text: 'No users currently have automatch turned on') + else + times = automatch_users.map do |user| + duration = ChronicDuration.output((user.automatch_time - Time.new).to_i, keep_zero: true) + "#{user.user_name} for #{duration}" + end.join("\n") + + client.say(channel: data.channel, text: times) + end + else + fail SlackGamebot::Error, "Invalid automatch argument '#{match['expression']}'" + end + end + + def self.automatch_on(challenger, client, data) + logger.info "AUTOMATCH: #{client.owner} - #{challenger.user_name}: #{challenger.automatch_time}" + + automatch_users = User.where(:automatch_time.gt => Time.now).order(elo: :asc).limit(4) + if automatch_users.count == 4 + challenge = ::Challenge.create!( + team: client.owner, + channel: data.channel, + created_by: challenger, + updated_by: automatch_users[1], + challengers: [automatch_users[0], automatch_users[3]], + challenged: [automatch_users[1], automatch_users[2]], + state: ChallengeState::ACCEPTED + ) + + automatch_users.each do |user| + user.automatch_time = nil + user.save! + end + + client.say(channel: data.channel, text: "Automatch: #{challenge.challengers.map(&:user_name).and} vs #{challenge.challenged.map(&:user_name).and}!", gif: 'challenge') + logger.info "CHALLENGE: #{client.owner} - #{challenge}" + else + client.say(channel: data.channel, text: "Automatch is on for #{challenger.user_name} (#{automatch_users.count} users ready to play!)", gif: 'ready') + end + end + + def self.automatch_off(challenger, client, data) + automatch_count = User.where(:automatch_time.gt => Time.now).count + logger.info "AUTOMATCH: #{client.owner} - #{challenger.user_name}: OFF" + + client.say(channel: data.channel, text: "Automatch is off for #{challenger.user_name} (#{automatch_count} users ready to play!)", gif: 'leave') + end + end + end +end diff --git a/slack-gamebot/commands/help.rb b/slack-gamebot/commands/help.rb index bbd14269..4c4550f5 100644 --- a/slack-gamebot/commands/help.rb +++ b/slack-gamebot/commands/help.rb @@ -17,6 +17,7 @@ class Help < SlackRubyBot::Commands::Base ----- challenge , ... [with , ...]: challenge opponent(s) to a game accept: accept a challenge +automatch: turn on automatch mode, eg. automatch for 10 minutes, or automatch until 8pm decline: decline a previous challenge cancel: cancel a previous challenge lost [score, ...]: record your loss diff --git a/slack-gamebot/models/user.rb b/slack-gamebot/models/user.rb index 6da6f12b..b0715007 100644 --- a/slack-gamebot/models/user.rb +++ b/slack-gamebot/models/user.rb @@ -11,6 +11,7 @@ class User field :tau, type: Float, default: 0 field :rank, type: Integer field :captain, type: Boolean, default: false + field :automatch_time, type: Time belongs_to :team, index: true validates_presence_of :team diff --git a/spec/fabricators/challenge_fabricator.rb b/spec/fabricators/challenge_fabricator.rb index f4c4d637..baa1d44a 100644 --- a/spec/fabricators/challenge_fabricator.rb +++ b/spec/fabricators/challenge_fabricator.rb @@ -42,3 +42,14 @@ instance.updated_by = instance.challenged.first end end + +Fabricator(:automatch_challenge, from: :challenge) do + state ChallengeState::ACCEPTED + after_build do |instance| + instance.challengers = [Fabricate(:user, team: instance.team), Fabricate(:user, team: instance.team)] unless instance.challengers.any? + instance.challenged = [Fabricate(:user, team: instance.team), Fabricate(:user, team: instance.team)] unless instance.challenged.any? + end + before_create do |instance| + instance.updated_by = instance.challenged.first + end +end diff --git a/spec/slack-gamebot/commands/automatch_spec.rb b/spec/slack-gamebot/commands/automatch_spec.rb new file mode 100644 index 00000000..10aae740 --- /dev/null +++ b/spec/slack-gamebot/commands/automatch_spec.rb @@ -0,0 +1,161 @@ +require 'spec_helper' + +describe SlackGamebot::Commands::Automatch, vcr: { cassette_name: 'user_info' } do + let!(:team) { Fabricate(:team) } + let(:app) { SlackGamebot::Server.new(team: team) } + let(:user1) { Fabricate(:user, user_name: 'username1', elo: 1) } + let(:user2) { Fabricate(:user, user_name: 'username2', elo: 2) } + let(:user3) { Fabricate(:user, user_name: 'username3', elo: 3) } + let(:user4) { Fabricate(:user, user_name: 'username4', elo: 4) } + let(:opponent) { Fabricate(:user) } + + it 'sets automatch time to 5 minutes in the future when user turns it on' do + Timecop.freeze(Time.now.beginning_of_minute) do + expect(message: "#{SlackRubyBot.config.user} automatch on", user: user1.user_id, channel: 'pongbot').to respond_with_slack_message( + 'Automatch is on for username (1 users ready to play!)' + ) + + user1.reload + expect(user1.automatch_time).to eq(5.minutes.from_now) + end + end + + it 'sets automatch flag to false when user turns it off' do + expect(message: "#{SlackRubyBot.config.user} automatch off", user: user1.user_id, channel: 'pongbot').to respond_with_slack_message( + 'Automatch is off for username (0 users ready to play!)' + ) + + user1.reload + expect(user1.automatch_time).to be(nil) + end + + it 'creates a doubles match when four users have automatch on' do + [user1, user2, user3].each do |user| + user.automatch_time = 5.minutes.from_now + user.save! + end + + expect do + expect(message: "#{SlackRubyBot.config.user} automatch on", user: user4.user_id, channel: 'pongbot').to respond_with_slack_message( + 'Automatch: username1 and username vs username2 and username3!' + ) + end.to change(Challenge, :count).by(1) + end + + it 'matches top and bottom elo vs middle two elo' do + [user1, user2, user3].each do |user| + user.automatch_time = 5.minutes.from_now + user.save! + end + + expect(message: "#{SlackRubyBot.config.user} automatch on", user: user4.user_id, channel: 'pongbot').to respond_with_slack_message( + 'Automatch: username1 and username vs username2 and username3!' + ) + + challenge = Challenge.last + expect(challenge.challengers.length).to eq(2) + expect(challenge.challengers.include?(user1)).to eq(true) + expect(challenge.challengers.include?(user4)).to eq(true) + expect(challenge.challenged.length).to eq(2) + expect(challenge.challenged.include?(user2)).to eq(true) + expect(challenge.challenged.include?(user3)).to eq(true) + end + + it 'times out after 5 minutes' do + [user1, user2, user3].each do |user| + user.automatch_time = 5.minutes.from_now + user.save! + end + + expect(message: "#{SlackRubyBot.config.user} automatch on", user: user4.user_id, channel: 'pongbot').to respond_with_slack_message( + 'Automatch: username1 and username vs username2 and username3!' + ) + + challenge = Challenge.last + expect(challenge.challengers.length).to eq(2) + expect(challenge.challengers.include?(user1)).to eq(true) + expect(challenge.challengers.include?(user4)).to eq(true) + expect(challenge.challenged.length).to eq(2) + expect(challenge.challenged.include?(user2)).to eq(true) + expect(challenge.challenged.include?(user3)).to eq(true) + end + + context 'until' do + it 'sets automatch time for relative time' do + Timecop.freeze(Time.now.beginning_of_minute) do + expect(message: "#{SlackRubyBot.config.user} automatch until 30 minutes from now", user: user1.user_id, channel: 'pongbot').to respond_with_slack_message( + 'Automatch is on for username (1 users ready to play!)' + ) + + user1.reload + expect(user1.automatch_time).to eq(30.minutes.from_now) + end + end + + it 'sets automatch time for specific time' do + Timecop.freeze(DateTime.parse('2016-1-1')) do + expect(message: "#{SlackRubyBot.config.user} automatch until June 20th, 2016 at 8pm", user: user1.user_id, channel: 'pongbot').to respond_with_slack_message( + 'Automatch is on for username (1 users ready to play!)' + ) + + user1.reload + expect(user1.automatch_time).to eq(DateTime.parse('2016-06-20 20:00-07:00')) + end + end + + it 'indicates when the time cannot be interpreted' do + expect(message: "#{SlackRubyBot.config.user} automatch until the cows come home", user: user1.user_id, channel: 'pongbot').to respond_with_slack_message( + "Can't understand time specified" + ) + end + end + + context 'for' do + it 'sets automatch time for relative time' do + Timecop.freeze(Time.now.beginning_of_minute) do + expect(message: "#{SlackRubyBot.config.user} automatch for 2 hours", user: user1.user_id, channel: 'pongbot').to respond_with_slack_message( + 'Automatch is on for username (1 users ready to play!)' + ) + + user1.reload + expect(user1.automatch_time).to eq(2.hours.from_now) + end + end + + it 'indicates when the time cannot be interpreted' do + expect(message: "#{SlackRubyBot.config.user} automatch for a really really long time", user: user1.user_id, channel: 'pongbot').to respond_with_slack_message( + "Can't understand time specified" + ) + end + end + + context 'with no arguments' do + it 'lists users and the times they are set to automatch until' do + Timecop.freeze(Time.now.beginning_of_minute) do + user1.automatch_time = 4.minutes.from_now + user1.save + + user2.automatch_time = 1.hour.from_now + user2.save + + expect(message: "#{SlackRubyBot.config.user} automatch", user: user1.user_id, channel: 'pongbot').to respond_with_slack_message( + "username for 4 mins 0 secs\nusername2 for 1 hr 0 secs" + ) + end + end + + it 'does not include users with times set in the past' do + Timecop.freeze(Time.now.beginning_of_minute) do + user1.automatch_time = 4.minutes.from_now + user1.save + + user2.automatch_time = 1.minute.ago + user2.save + + expect(message: "#{SlackRubyBot.config.user} automatch", user: user1.user_id, channel: 'pongbot').to respond_with_slack_message( + 'username for 4 mins 0 secs' + ) + end + end + end +end diff --git a/spec/slack-gamebot/commands/lost_spec.rb b/spec/slack-gamebot/commands/lost_spec.rb index 773b64fe..3780f5a8 100644 --- a/spec/slack-gamebot/commands/lost_spec.rb +++ b/spec/slack-gamebot/commands/lost_spec.rb @@ -5,7 +5,7 @@ let(:app) { SlackGamebot::Server.new(team: team) } context 'with an existing challenge' do let(:challenged) { Fabricate(:user, user_name: 'username') } - let!(:challenge) { Fabricate(:challenge, challenged: [challenged]) } + let(:challenge) { Fabricate(:challenge, challenged: [challenged]) } before do challenge.accept!(challenged) end @@ -85,6 +85,20 @@ ) end end + context 'automatch challenge' do + let(:challenged) { Fabricate(:user, user_name: 'username') } + let(:teammate) { Fabricate(:user) } + let!(:challenge) { Fabricate(:automatch_challenge, challenged: [challenged, teammate]) } + it 'lost' do + expect(message: "#{SlackRubyBot.config.user} lost", user: challenged.user_id, channel: challenge.channel).to respond_with_slack_message( + "Match has been recorded! #{challenge.challengers.map(&:user_name).and} defeated #{challenge.challenged.map(&:user_name).and}." + ) + challenge.reload + expect(challenge.state).to eq ChallengeState::PLAYED + expect(challenge.match.winners).to eq challenge.challengers + expect(challenge.match.losers).to eq challenge.challenged + end + end context 'lost to' do let(:loser) { Fabricate(:user, user_name: 'username') } let(:winner) { Fabricate(:user) }