diff --git a/app/controllers/match_scores_controller.rb b/app/controllers/match_scores_controller.rb index 92f7dc0..f4d3014 100644 --- a/app/controllers/match_scores_controller.rb +++ b/app/controllers/match_scores_controller.rb @@ -29,6 +29,6 @@ class MatchScoresController < ApplicationController # Only allow a trusted parameter "white list" through. def match_score_params - params.slice(:points).permit! + params.slice(:points, :hidden_points).permit! end end diff --git a/app/controllers/matches_controller.rb b/app/controllers/matches_controller.rb index 5224479..353e102 100644 --- a/app/controllers/matches_controller.rb +++ b/app/controllers/matches_controller.rb @@ -2,8 +2,11 @@ class MatchesController < ApplicationController before_action :set_match, only: %i[show update] + before_action :set_group, only: %i[create] + before_action :authenticate_user!, only: %i[create update] before_action :validate_params, only: %i[update] before_action -> { require_owner! @match.owner }, only: %i[update] + before_action -> { require_owner! @group.owner }, only: %i[create] before_action :set_tournament, only: %i[index] # GET/tournaments/1/matches @@ -44,6 +47,20 @@ class MatchesController < ApplicationController render json: @match, include: %w[match_scores.points match_scores.team bets] end + # POST /groups/:group_id/matches + def create + match = build_decider_match + + if match.errors.any? + render json: match.errors, status: :unprocessable_entity + elsif match.save + UpdateGroupsGroupScoresAndSave.call(group: @group) + render json: match, include: %w[match_scores.team group], status: :created + else + render json: match.errors, status: :unprocessable_entity + end + end + # PATCH/PUT /matches/1 def update new_state = match_params['state'] @@ -112,7 +129,32 @@ class MatchesController < ApplicationController @tournament = Tournament.find(params[:tournament_id]) end + def set_group + @group = Group.find(params[:group_id]) + end + def match_params params.slice(:state).permit! end + + def create_match_params + params.slice(:team_ids).permit(team_ids: []) + end + + def build_decider_match + team_ids = create_match_params[:team_ids].to_a.map(&:to_i).uniq + teams = @group.teams.select { |team| team_ids.include?(team.id) } + + Match.new( + group: @group, + state: :not_started, + position: (@group.matches.maximum(:position) || -1) + 1, + decider: true, + match_scores: teams.map { |team| MatchScore.new(team: team) } + ).tap do |match| + if team_ids.size != 2 || teams.size != 2 + match.errors.add(:team_ids, 'must contain exactly two teams from this group') + end + end + end end diff --git a/app/controllers/stages_controller.rb b/app/controllers/stages_controller.rb index cdc5f1b..800204d 100644 --- a/app/controllers/stages_controller.rb +++ b/app/controllers/stages_controller.rb @@ -45,6 +45,22 @@ class StagesController < ApplicationController raise ActiveRecord::Rollback end + @stage.groups.each do |group| + next if UpdateGroupsGroupScoresAndSave.call(group: group).success? + + render json: { error: 'Updating group scores failed' }, status: :unprocessable_entity + raise ActiveRecord::Rollback + end + + blocking_ties = GroupStageService.blocking_ties_for(@stage) + if blocking_ties.any? + render json: { + error: 'Playoffs cannot be generated yet because a decider match is required for an advancing place', + blocking_ties: blocking_ties + }, status: :unprocessable_entity + raise ActiveRecord::Rollback + end + return if AddPlayoffsToTournamentAndSave.call(tournament: @stage.tournament, teams: GroupStageService.get_advancing_teams(@stage)).success? diff --git a/app/models/group_score.rb b/app/models/group_score.rb index 020ba81..325efa5 100644 --- a/app/models/group_score.rb +++ b/app/models/group_score.rb @@ -23,6 +23,8 @@ class GroupScore < ApplicationRecord public_send(COMPARISON_ATTRIBUTES[comparison_reason[:resolved_by]]) when :direct_comparison comparison_reason[:direct_comparison_result] == :won ? -1 : 1 + when :hidden_points + comparison_reason[:hidden_points_result] == :won ? -1 : 1 else 0 end @@ -36,16 +38,23 @@ class GroupScore < ApplicationRecord return { resolved_by: reason, tied: false, - direct_comparison_result: nil + direct_comparison_result: nil, + hidden_points_result: :not_played, + direct_comparison_match_id: nil, + decider_match_id: nil } end direct_comparison_result = direct_comparison_result_against(other) + hidden_points_result = hidden_points_result_against(other) { - resolved_by: direct_comparison_result.nil? ? :tie : :direct_comparison, - tied: direct_comparison_result.nil?, - direct_comparison_result: direct_comparison_result || direct_comparison_tie_reason_against(other) + resolved_by: resolved_by_for(direct_comparison_result, hidden_points_result), + tied: direct_comparison_result.nil? && hidden_points_result.nil?, + direct_comparison_result: direct_comparison_result || direct_comparison_tie_reason_against(other), + hidden_points_result: hidden_points_result || hidden_points_tie_reason_against(other), + direct_comparison_match_id: direct_comparison_match_against(other)&.id, + decider_match_id: decider_match_against(other)&.id } end @@ -68,11 +77,47 @@ class GroupScore < ApplicationRecord :draw end + def hidden_points_result_against(other) + decider_match = decider_match_against(other) + return nil if decider_match.nil? || !decider_match.finished? + + team_hidden_points = decider_match.hidden_points_of(team) + other_team_hidden_points = decider_match.hidden_points_of(other.team) + return :won if team_hidden_points > other_team_hidden_points + return :lost if team_hidden_points < other_team_hidden_points + + nil + end + + def hidden_points_tie_reason_against(other) + decider_match = decider_match_against(other) + return :not_played if decider_match.nil? || !decider_match.finished? + + :draw + end + private + def resolved_by_for(direct_comparison_result, hidden_points_result) + return :direct_comparison unless direct_comparison_result.nil? + return :hidden_points unless hidden_points_result.nil? + + :tie + end + def direct_comparison_match_against(other) - group.matches.reload.find do |match| - match.finished? && match.teams.include?(team) && match.teams.include?(other.team) - end + comparison_match_against(other, decider: false) + end + + def decider_match_against(other) + comparison_match_against(other, decider: true) + end + + def comparison_match_against(other, decider:) + group.matches.reload + .select do |match| + match.decider? == decider && match.teams.include?(team) && match.teams.include?(other.team) + end + .max_by(&:position) end end diff --git a/app/models/match.rb b/app/models/match.rb index 6f0a089..9500c65 100644 --- a/app/models/match.rb +++ b/app/models/match.rb @@ -23,9 +23,10 @@ class Match < ApplicationRecord end def current_leading_team - return nil if match_scores.first.points == match_scores.second.points + return nil if match_scores.size < 2 + return nil if score_value_for(match_scores.first) == score_value_for(match_scores.second) - match_scores.max_by(&:points).team + match_scores.max_by { |match_score| score_value_for(match_score) }.team end def winner @@ -39,14 +40,19 @@ class Match < ApplicationRecord end def scored_points_of(team) + return 0 if decider? + teams.include?(team) ? match_scores.find_by(team: team).points : 0 end def received_points_of(team) + return 0 if decider? + teams.include?(team) ? match_scores.find { |ms| ms.team != team }.points : 0 end def group_points_of(team) + return 0 if decider? return 0 unless (finished? || in_progress?) && teams.include?(team) case current_leading_team @@ -59,9 +65,17 @@ class Match < ApplicationRecord end end + def hidden_points_of(team) + teams.include?(team) ? match_scores.find_by(team: team).hidden_points : 0 + end + private def stage_xor_group errors.add(:stage_xor_group, 'Stage and Group missing or both present') unless stage.present? ^ group.present? end + + def score_value_for(match_score) + decider? ? match_score.hidden_points : match_score.points + end end diff --git a/app/models/stage.rb b/app/models/stage.rb index 97eb036..3466c8e 100644 --- a/app/models/stage.rb +++ b/app/models/stage.rb @@ -23,7 +23,8 @@ class Stage < ApplicationRecord return matches.find { |m| m.state != 'finished' }.nil? unless matches.size.zero? unless groups.size.zero? && groups.map(&:matches).flatten.size.zero? - return groups.map(&:matches).flatten.find { |m| m.state != 'finished' }.nil? + relevant_matches = groups.map(&:matches).flatten.reject(&:decider?) + return relevant_matches.find { |m| m.state != 'finished' }.nil? end false diff --git a/app/serializers/match_score_serializer.rb b/app/serializers/match_score_serializer.rb index bb62f7e..21991cc 100644 --- a/app/serializers/match_score_serializer.rb +++ b/app/serializers/match_score_serializer.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class MatchScoreSerializer < ApplicationSerializer - attributes :points + attributes :points, :hidden_points belongs_to :team end diff --git a/app/serializers/match_serializer.rb b/app/serializers/match_serializer.rb index 28b7d1a..50feade 100644 --- a/app/serializers/match_serializer.rb +++ b/app/serializers/match_serializer.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class MatchSerializer < ApplicationSerializer - attributes :state, :position, :winner + attributes :state, :position, :winner, :decider def winner ActiveModelSerializers::SerializableResource.new(object.winner).as_json diff --git a/app/services/group_stage_service.rb b/app/services/group_stage_service.rb index e9fc32d..735fd4d 100644 --- a/app/services/group_stage_service.rb +++ b/app/services/group_stage_service.rb @@ -112,12 +112,38 @@ class GroupStageService compared_with_team_id: compared_group_score.team.id, compared_with_team_name: compared_group_score.team.name, direct_comparison_result: comparison_reason[:direct_comparison_result].to_s, + direct_comparison_match_id: comparison_reason[:direct_comparison_match_id], + hidden_points_result: comparison_reason[:hidden_points_result].to_s, + decider_match_id: comparison_reason[:decider_match_id], tied: comparison_reason[:tied], needs_decider_match: comparison_reason[:tied] && unresolved_tie_crosses_advancing_cutoff?(sorted_group_scores, group_score, advancing_slots_for_group(group_score.group)) } end + def blocking_ties_for(group_stage) + return [] if group_stage.nil? + + group_stage.groups.flat_map do |group| + sorted_scores = group.group_scores.reload.sort + sorted_scores.filter_map do |group_score| + decision = ranking_decision_for(group_score) + next unless decision&.fetch(:needs_decider_match, false) + + comparison_team_id = decision[:compared_with_team_id] + next if comparison_team_id.nil? || group_score.team.id > comparison_team_id + + { + group_id: group.id, + group_number: group.number, + team_ids: [group_score.team.id, comparison_team_id], + team_names: [group_score.team.name, decision[:compared_with_team_name]], + ranking_decision: decision + } + end + end + end + # Returns all teams advancing to playoff stage from given group stage # They are ordered in such a way, that PlayoffStageService will correctly match the teams # diff --git a/config/routes.rb b/config/routes.rb index 9a6cf02..0351e81 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -9,6 +9,9 @@ Rails.application.routes.draw do resources :matches, only: %i[show update] do resources :bets, only: %i[index create] end + resources :groups, only: %i[show] do + resources :matches, only: %i[create] + end resources :stages, only: %i[show update] resources :teams, only: %i[show update] resources :tournaments do @@ -20,7 +23,5 @@ Rails.application.routes.draw do end end resources :match_scores, only: %i[show update] - resources :groups, only: %i[show] - get 'healthz', to: 'health#index' end diff --git a/db/migrate/20260412110000_add_decider_support_to_group_matches.rb b/db/migrate/20260412110000_add_decider_support_to_group_matches.rb new file mode 100644 index 0000000..ece1da6 --- /dev/null +++ b/db/migrate/20260412110000_add_decider_support_to_group_matches.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class AddDeciderSupportToGroupMatches < ActiveRecord::Migration[7.0] + def change + add_column :matches, :decider, :boolean, null: false, default: false + add_column :match_scores, :hidden_points, :integer, null: false, default: 0 + end +end diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb index 111b54a..5afecae 100644 --- a/spec/controllers/groups_controller_spec.rb +++ b/spec/controllers/groups_controller_spec.rb @@ -50,6 +50,7 @@ RSpec.describe GroupsController, type: :controller do resolved_by: 'direct_comparison', compared_with_team_name: 'B', direct_comparison_result: 'won', + hidden_points_result: 'not_played', tied: false, needs_decider_match: false ) @@ -57,6 +58,7 @@ RSpec.describe GroupsController, type: :controller do resolved_by: 'direct_comparison', compared_with_team_name: 'A', direct_comparison_result: 'lost', + hidden_points_result: 'not_played', tied: false, needs_decider_match: false ) diff --git a/spec/controllers/match_scores_controller_spec.rb b/spec/controllers/match_scores_controller_spec.rb index 8e34133..a108f40 100644 --- a/spec/controllers/match_scores_controller_spec.rb +++ b/spec/controllers/match_scores_controller_spec.rb @@ -25,7 +25,8 @@ RSpec.describe MatchScoresController, type: :controller do describe 'PUT #update' do let(:valid_update) do { - points: 42 + points: 42, + hidden_points: 13 } end @@ -39,6 +40,7 @@ RSpec.describe MatchScoresController, type: :controller do put :update, params: { id: @match_score.to_param }.merge(valid_update) @match_score.reload expect(@match_score.points).to eq(valid_update[:points]) + expect(@match_score.hidden_points).to eq(valid_update[:hidden_points]) end it 'renders a response with the updated team' do @@ -46,6 +48,7 @@ RSpec.describe MatchScoresController, type: :controller do expect(response).to be_successful body = deserialize_response response expect(body[:points]).to eq(valid_update[:points]) + expect(body[:hidden_points]).to eq(valid_update[:hidden_points]) end end @@ -86,7 +89,8 @@ RSpec.describe MatchScoresController, type: :controller do let(:valid_update) do { - points: 42 + points: 42, + hidden_points: 13 } end diff --git a/spec/controllers/matches_controller_spec.rb b/spec/controllers/matches_controller_spec.rb index a283b63..6e270dc 100644 --- a/spec/controllers/matches_controller_spec.rb +++ b/spec/controllers/matches_controller_spec.rb @@ -61,6 +61,41 @@ RSpec.describe MatchesController, type: :controller do end end + describe 'POST #create' do + let(:group_stage_tournament) { create(:group_stage_tournament, group_count: 1, match_factory: :finished_group_match) } + let(:group) { group_stage_tournament.stages.find_by(level: -1).groups.first } + let(:team_ids) { group.teams.first(2).map(&:id) } + + context 'as owner' do + before do + apply_authentication_headers_for group.owner + end + + it 'creates a decider match at the end of the group' do + expect do + post :create, params: { group_id: group.to_param, team_ids: team_ids } + end.to change { group.matches.reload.count }.by(1) + + expect(response).to have_http_status(:created) + created_match = group.matches.reload.max_by(&:position) + expect(created_match.decider?).to eq(true) + expect(created_match.state).to eq('not_started') + expect(created_match.teams.map(&:id)).to match_array(team_ids) + end + end + + context 'with invalid teams' do + before do + apply_authentication_headers_for group.owner + end + + it 'rejects the request' do + post :create, params: { group_id: group.to_param, team_ids: [group.teams.first.id] } + expect(response).to have_http_status(:unprocessable_entity) + end + end + end + describe 'POST #update' do context 'on a running playoff match' do let(:valid_update) do diff --git a/spec/controllers/stages_controller_spec.rb b/spec/controllers/stages_controller_spec.rb index a292400..8a66ced 100644 --- a/spec/controllers/stages_controller_spec.rb +++ b/spec/controllers/stages_controller_spec.rb @@ -136,5 +136,41 @@ RSpec.describe StagesController, type: :controller do expect(deserialize_response(response)[:error]).to eq('The state attribute may only be changed to finished') end end + + context 'when a tie around the advancing cutoff still needs a decider match' do + let(:group_stage) do + create(:group_stage, tournament: create(:tournament, teams_count: 0), group_count: 0) + end + + before do + tournament = group_stage.tournament + tournament.update!(playoff_teams_amount: 1, instant_finalists_amount: 1, intermediate_round_participants_amount: 0) + group = create(:group, stage: group_stage, match_count: 0, number: 1) + group_stage.groups = [group] + team_a = create(:team, tournament: tournament, name: 'A') + team_b = create(:team, tournament: tournament, name: 'B') + create(:group_match, group: group, state: :finished).tap do |match| + create(:match_score, match: match, team: team_a, points: 2) + create(:match_score, match: match, team: team_b, points: 2) + end + score_a = create(:group_score, group: group, team: team_a, group_points: 7, scored_points: 4, received_points: 1) + score_b = create(:group_score, group: group, team: team_b, group_points: 7, scored_points: 4, received_points: 1) + GroupStageService.send(:recalculate_position_of_group_scores!, [score_a, score_b]).each(&:save!) + + apply_authentication_headers_for group_stage.owner + put :update, params: { id: group_stage.to_param }.merge(finished) + end + + it 'returns unprocessable entity' do + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'returns a helpful decider error' do + body = deserialize_response(response) + expect(body[:error]).to eq('Playoffs cannot be generated yet because a decider match is required for an advancing place') + expect(body[:blocking_ties].size).to eq(1) + expect(body[:blocking_ties].first[:team_names]).to eq(%w[A B]) + end + end end end diff --git a/spec/factories/matches.rb b/spec/factories/matches.rb index 6b9facd..9e10eb0 100644 --- a/spec/factories/matches.rb +++ b/spec/factories/matches.rb @@ -44,6 +44,10 @@ FactoryBot.define do factory :group_match, class: Match do group position { 0 } + + trait :decider do + decider { true } + end factory :filled_group_match do transient do match_scores_count { 2 } diff --git a/spec/models/group_score_spec.rb b/spec/models/group_score_spec.rb index 7a9490b..2e4e184 100644 --- a/spec/models/group_score_spec.rb +++ b/spec/models/group_score_spec.rb @@ -69,7 +69,10 @@ RSpec.describe GroupScore, type: :model do expect(group_score_a.comparison_reason_against(group_score_b)).to eq( resolved_by: :group_points, tied: false, - direct_comparison_result: nil + direct_comparison_result: nil, + hidden_points_result: :not_played, + direct_comparison_match_id: nil, + decider_match_id: nil ) end @@ -83,7 +86,10 @@ RSpec.describe GroupScore, type: :model do expect(group_score_a.comparison_reason_against(group_score_b)).to eq( resolved_by: :direct_comparison, tied: false, - direct_comparison_result: :won + direct_comparison_result: :won, + hidden_points_result: :not_played, + direct_comparison_match_id: group.matches.last.id, + decider_match_id: nil ) end @@ -97,7 +103,31 @@ RSpec.describe GroupScore, type: :model do expect(group_score_a.comparison_reason_against(group_score_b)).to eq( resolved_by: :tie, tied: true, - direct_comparison_result: :draw + direct_comparison_result: :draw, + hidden_points_result: :not_played, + direct_comparison_match_id: group.matches.last.id, + decider_match_id: nil + ) + end + + it 'falls back to hidden points when direct comparison is still tied' do + group_score_b.update!(group_points: 6) + create(:group_match, group: group, state: :finished).tap do |match| + create(:match_score, match: match, team: team_a, points: 2) + create(:match_score, match: match, team: team_b, points: 2) + end + create(:group_match, group: group, state: :finished, decider: true).tap do |match| + create(:match_score, match: match, team: team_a, hidden_points: 3) + create(:match_score, match: match, team: team_b, hidden_points: 1) + end + + expect(group_score_a.comparison_reason_against(group_score_b)).to eq( + resolved_by: :hidden_points, + tied: false, + direct_comparison_result: :draw, + hidden_points_result: :won, + direct_comparison_match_id: group.matches.reject(&:decider?).last.id, + decider_match_id: group.matches.select(&:decider?).last.id ) end end diff --git a/spec/routing/matches_routing_spec.rb b/spec/routing/matches_routing_spec.rb index f0f4806..9b5918d 100644 --- a/spec/routing/matches_routing_spec.rb +++ b/spec/routing/matches_routing_spec.rb @@ -7,5 +7,9 @@ RSpec.describe MatchesController, type: :routing do it 'routes to #show' do expect(get: '/matches/1').to route_to('matches#show', id: '1') end + + it 'routes nested group matches to #create' do + expect(post: '/groups/1/matches').to route_to('matches#create', group_id: '1') + end end end diff --git a/spec/services/group_stage_service_spec.rb b/spec/services/group_stage_service_spec.rb index eec7149..1cf60fb 100644 --- a/spec/services/group_stage_service_spec.rb +++ b/spec/services/group_stage_service_spec.rb @@ -246,6 +246,7 @@ RSpec.describe GroupStageService do resolved_by: 'direct_comparison', compared_with_team_id: team_b.id, direct_comparison_result: 'won', + hidden_points_result: 'not_played', tied: false, needs_decider_match: false ) @@ -253,6 +254,7 @@ RSpec.describe GroupStageService do resolved_by: 'direct_comparison', compared_with_team_id: team_a.id, direct_comparison_result: 'lost', + hidden_points_result: 'not_played', tied: false, needs_decider_match: false ) @@ -277,12 +279,14 @@ RSpec.describe GroupStageService do expect(GroupStageService.ranking_decision_for(score_a)).to include( resolved_by: 'tie', direct_comparison_result: 'draw', + hidden_points_result: 'not_played', tied: true, needs_decider_match: true ) expect(GroupStageService.ranking_decision_for(score_b)).to include( resolved_by: 'tie', direct_comparison_result: 'draw', + hidden_points_result: 'not_played', tied: true, needs_decider_match: true ) @@ -301,15 +305,46 @@ RSpec.describe GroupStageService do expect([score_a.position, score_b.position, score_c.position, score_d.position]).to eq([1, 2, 3, 3]) expect(GroupStageService.ranking_decision_for(score_c)).to include( resolved_by: 'tie', + hidden_points_result: 'not_played', tied: true, needs_decider_match: false ) expect(GroupStageService.ranking_decision_for(score_d)).to include( resolved_by: 'tie', + hidden_points_result: 'not_played', tied: true, needs_decider_match: false ) end + + it 'uses the decider hidden points to resolve a cutoff tie' do + tournament.update!(playoff_teams_amount: 1, instant_finalists_amount: 1, intermediate_round_participants_amount: 0) + + create(:group_match, group: group, state: :finished).tap do |match| + create(:match_score, match: match, team: team_a, points: 2) + create(:match_score, match: match, team: team_b, points: 2) + end + create(:group_match, group: group, state: :finished, decider: true).tap do |match| + create(:match_score, match: match, team: team_a, hidden_points: 5) + create(:match_score, match: match, team: team_b, hidden_points: 3) + end + + score_a = create(:group_score, group: group, team: team_a, group_points: 7, scored_points: 4, received_points: 1) + score_b = create(:group_score, group: group, team: team_b, group_points: 7, scored_points: 4, received_points: 1) + score_c = create(:group_score, group: group, team: team_c, group_points: 3, scored_points: 2, received_points: 3) + score_d = create(:group_score, group: group, team: team_d, group_points: 0, scored_points: 1, received_points: 7) + + GroupStageService.send(:recalculate_position_of_group_scores!, [score_a, score_b, score_c, score_d]).each(&:save!) + + expect([score_a.position, score_b.position, score_c.position, score_d.position]).to eq([1, 2, 3, 4]) + expect(GroupStageService.ranking_decision_for(score_a)).to include( + resolved_by: 'hidden_points', + direct_comparison_result: 'draw', + hidden_points_result: 'won', + tied: false, + needs_decider_match: false + ) + end end describe '#get_advancing_teams' do