From eedafe1276b76c2171afa5d27f1e03796a86e51d Mon Sep 17 00:00:00 2001 From: Malaber Date: Sun, 12 Apr 2026 17:47:28 +0200 Subject: [PATCH] Add ranking decision metadata for group scores --- app/models/group_score.rb | 70 +++++++++++--- app/serializers/group_score_serializer.rb | 6 +- app/services/group_stage_service.rb | 73 +++++++++++++++ spec/controllers/groups_controller_spec.rb | 40 ++++++++ spec/models/group_score_spec.rb | 49 ++++++++++ spec/services/group_stage_service_spec.rb | 103 +++++++++++++++++++++ 6 files changed, 328 insertions(+), 13 deletions(-) diff --git a/app/models/group_score.rb b/app/models/group_score.rb index 7f532d5..020ba81 100644 --- a/app/models/group_score.rb +++ b/app/models/group_score.rb @@ -4,29 +4,75 @@ class GroupScore < ApplicationRecord belongs_to :team belongs_to :group + COMPARISON_ATTRIBUTES = { + group_points: :group_points, + difference_in_points: :difference_in_points, + scored_points: :scored_points + }.freeze + def difference_in_points scored_points - received_points end def <=>(other) - point_comparison = [-group_points, -difference_in_points, -scored_points] <=> [-other.group_points, -other.difference_in_points, -other.scored_points] - return point_comparison unless point_comparison.zero? + comparison_reason = comparison_reason_against(other) - direct_comparison(other) + case comparison_reason[:resolved_by] + when :group_points, :difference_in_points, :scored_points + other.public_send(COMPARISON_ATTRIBUTES[comparison_reason[:resolved_by]]) <=> + public_send(COMPARISON_ATTRIBUTES[comparison_reason[:resolved_by]]) + when :direct_comparison + comparison_reason[:direct_comparison_result] == :won ? -1 : 1 + else + 0 + end + end + + def comparison_reason_against(other) + COMPARISON_ATTRIBUTES.each do |reason, attribute| + comparison = public_send(attribute) <=> other.public_send(attribute) + next if comparison.zero? + + return { + resolved_by: reason, + tied: false, + direct_comparison_result: nil + } + end + + direct_comparison_result = direct_comparison_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) + } + end + + def direct_comparison_result_against(other) + comparison_match = direct_comparison_match_against(other) + return nil if comparison_match.nil? + + team_points = comparison_match.scored_points_of(team) + other_team_points = comparison_match.scored_points_of(other.team) + return :won if team_points > other_team_points + return :lost if team_points < other_team_points + + nil + end + + def direct_comparison_tie_reason_against(other) + comparison_match = direct_comparison_match_against(other) + return :not_played if comparison_match.nil? + + :draw end private - def direct_comparison(other) - comparison_match = group.matches.find do |match| + def direct_comparison_match_against(other) + group.matches.reload.find do |match| match.finished? && match.teams.include?(team) && match.teams.include?(other.team) end - return 0 if comparison_match.nil? - - team_points = comparison_match.scored_points_of(team) - other_team_points = comparison_match.scored_points_of(other.team) - return 0 if team_points == other_team_points - - other_team_points <=> team_points end end diff --git a/app/serializers/group_score_serializer.rb b/app/serializers/group_score_serializer.rb index 42f5493..d0c8ef4 100644 --- a/app/serializers/group_score_serializer.rb +++ b/app/serializers/group_score_serializer.rb @@ -1,7 +1,11 @@ # frozen_string_literal: true class GroupScoreSerializer < ApplicationSerializer - attributes :group_points, :received_points, :scored_points, :position, :difference_in_points + attributes :group_points, :received_points, :scored_points, :position, :difference_in_points, :ranking_decision belongs_to :team + + def ranking_decision + GroupStageService.ranking_decision_for(object) + end end diff --git a/app/services/group_stage_service.rb b/app/services/group_stage_service.rb index c4b545c..e9fc32d 100644 --- a/app/services/group_stage_service.rb +++ b/app/services/group_stage_service.rb @@ -96,6 +96,28 @@ class GroupStageService group.group_scores.sort.map(&:team) end + def ranking_decision_for(group_score) + sorted_group_scores = group_score.group.group_scores.reload.sort + index = sorted_group_scores.index(group_score) + return nil if index.nil? || sorted_group_scores.size <= 1 + + comparison_index = comparison_partner_index(sorted_group_scores, index) + return nil if comparison_index.nil? + + compared_group_score = sorted_group_scores[comparison_index] + comparison_reason = group_score.comparison_reason_against(compared_group_score) + + { + resolved_by: comparison_reason[:resolved_by].to_s, + 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, + 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 + # 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 # @@ -187,5 +209,56 @@ class GroupStageService group_scores end + + def comparison_partner_index(sorted_group_scores, index) + return index - 1 if index.positive? && sorted_group_scores[index - 1].position == sorted_group_scores[index].position + return index + 1 if index < sorted_group_scores.size - 1 && + sorted_group_scores[index + 1].position == sorted_group_scores[index].position + return index - 1 if index.positive? + return index + 1 if sorted_group_scores.size > 1 + + nil + end + + def unresolved_tie_crosses_advancing_cutoff?(sorted_group_scores, group_score, advancing_slots) + return false if advancing_slots.zero? + + cluster = tie_cluster_for(sorted_group_scores, group_score) + occupied_slots = (cluster[:start_index] + 1)..(cluster[:end_index] + 1) + occupied_slots.begin <= advancing_slots && occupied_slots.end > advancing_slots + end + + def tie_cluster_for(sorted_group_scores, group_score) + index = sorted_group_scores.index(group_score) + position = group_score.position + + start_index = index + start_index -= 1 while start_index.positive? && sorted_group_scores[start_index - 1].position == position + + end_index = index + while end_index < sorted_group_scores.size - 1 && sorted_group_scores[end_index + 1].position == position + end_index += 1 + end + + { start_index: start_index, end_index: end_index } + end + + def advancing_slots_for_group(group) + group_stage = group.stage + return 0 if group_stage.nil? || group_stage.level != -1 || group_stage.tournament.nil? + + groups = group_stage.groups.sort_by(&:number) + group_index = groups.index(group) + return 0 if group_index.nil? + + total_advancing_teams = calculate_advancing_teams_amount(group_stage) + if special_case_for_po2?(group_stage.tournament.teams.size, total_advancing_teams) + total_advancing_teams / groups.size + else + base_slots = total_advancing_teams / groups.size + extra_slot_groups = total_advancing_teams % groups.size + base_slots + (group_index < extra_slot_groups ? 1 : 0) + end + end end end diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb index 6781b63..111b54a 100644 --- a/spec/controllers/groups_controller_spec.rb +++ b/spec/controllers/groups_controller_spec.rb @@ -21,5 +21,45 @@ RSpec.describe GroupsController, type: :controller do expect(body[:matches].size).to eq(@group.matches.size) expect(body[:group_scores].size).to eq(@group.group_scores.size) end + + it 'returns ranking_decision metadata for group scores' do + tournament = @group.stage.tournament + tournament.update!(playoff_teams_amount: 2, instant_finalists_amount: 2, intermediate_round_participants_amount: 0) + @group.stage.update!(level: -1) + @group.matches.destroy_all + @group.group_scores.destroy_all + + 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: 3) + create(:match_score, match: match, team: team_b, points: 1) + end + + score_a = create(:group_score, group: @group, team: team_a, group_points: 3, scored_points: 3, received_points: 1) + score_b = create(:group_score, group: @group, team: team_b, group_points: 3, scored_points: 3, received_points: 1) + GroupStageService.send(:recalculate_position_of_group_scores!, [score_a, score_b]).each(&:save!) + + get :show, params: { id: @group.to_param } + body = deserialize_response response + winner = body[:group_scores].find { |score| score.dig(:team, :name) == 'A' } + loser = body[:group_scores].find { |score| score.dig(:team, :name) == 'B' } + + expect(winner[:ranking_decision]).to include( + resolved_by: 'direct_comparison', + compared_with_team_name: 'B', + direct_comparison_result: 'won', + tied: false, + needs_decider_match: false + ) + expect(loser[:ranking_decision]).to include( + resolved_by: 'direct_comparison', + compared_with_team_name: 'A', + direct_comparison_result: 'lost', + tied: false, + needs_decider_match: false + ) + end end end diff --git a/spec/models/group_score_spec.rb b/spec/models/group_score_spec.rb index f0bfbe7..7a9490b 100644 --- a/spec/models/group_score_spec.rb +++ b/spec/models/group_score_spec.rb @@ -52,4 +52,53 @@ RSpec.describe GroupScore, type: :model do expect(group_score_b <=> group_score_a).to eq(0) end end + + describe '#comparison_reason_against' do + let(:group) { create(:group, match_count: 0) } + let(:team_a) { create(:team) } + let(:team_b) { create(:team) } + + let(:group_score_a) do + create(:group_score, group: group, team: team_a, group_points: 6, scored_points: 7, received_points: 4) + end + let(:group_score_b) do + create(:group_score, group: group, team: team_b, group_points: 3, scored_points: 7, received_points: 4) + end + + it 'reports the first deciding metric when points are different' do + expect(group_score_a.comparison_reason_against(group_score_b)).to eq( + resolved_by: :group_points, + tied: false, + direct_comparison_result: nil + ) + end + + it 'reports the direct comparison result when head-to-head resolves the tie' 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: 3) + create(:match_score, match: match, team: team_b, points: 1) + end + + expect(group_score_a.comparison_reason_against(group_score_b)).to eq( + resolved_by: :direct_comparison, + tied: false, + direct_comparison_result: :won + ) + end + + it 'reports an unresolved tie when head-to-head was a draw' 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 + + expect(group_score_a.comparison_reason_against(group_score_b)).to eq( + resolved_by: :tie, + tied: true, + direct_comparison_result: :draw + ) + end + end end diff --git a/spec/services/group_stage_service_spec.rb b/spec/services/group_stage_service_spec.rb index 2d83249..eec7149 100644 --- a/spec/services/group_stage_service_spec.rb +++ b/spec/services/group_stage_service_spec.rb @@ -209,6 +209,109 @@ RSpec.describe GroupStageService do end end + describe '.ranking_decision_for' do + let(:tournament) do + create(:prepared_group_stage_tournament, + teams_count: 0, + playoff_teams_amount: 2, + instant_finalists_amount: 2, + intermediate_round_participants_amount: 0, + group_stage: group_stage) + end + let(:group_stage) { create(:group_stage, tournament: create(:tournament, teams_count: 0), group_count: 0) } + let(:group) { create(:group, stage: group_stage, match_count: 0, number: 1) } + let(:team_a) { create(:team, tournament: tournament, name: 'A') } + let(:team_b) { create(:team, tournament: tournament, name: 'B') } + let(:team_c) { create(:team, tournament: tournament, name: 'C') } + let(:team_d) { create(:team, tournament: tournament, name: 'D') } + + before do + group_stage.groups = [group] + group_stage.level = -1 + group_stage.save! + end + + it 'returns direct-comparison metadata for teams separated by head-to-head' do + create(:group_match, group: group, state: :finished).tap do |match| + create(:match_score, match: match, team: team_a, points: 4) + create(:match_score, match: match, team: team_b, points: 2) + end + + score_a = create(:group_score, group: group, team: team_a, group_points: 3, scored_points: 4, received_points: 2) + score_b = create(:group_score, group: group, team: team_b, group_points: 3, scored_points: 4, received_points: 2) + + GroupStageService.send(:recalculate_position_of_group_scores!, [score_a, score_b]).each(&:save!) + + expect(GroupStageService.ranking_decision_for(score_a)).to include( + resolved_by: 'direct_comparison', + compared_with_team_id: team_b.id, + direct_comparison_result: 'won', + tied: false, + needs_decider_match: false + ) + expect(GroupStageService.ranking_decision_for(score_b)).to include( + resolved_by: 'direct_comparison', + compared_with_team_id: team_a.id, + direct_comparison_result: 'lost', + tied: false, + needs_decider_match: false + ) + end + + it 'keeps shared positions and requests a decider only when an unresolved tie crosses the cutoff' 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 + + 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, 1, 3, 4]) + expect(GroupStageService.ranking_decision_for(score_a)).to include( + resolved_by: 'tie', + direct_comparison_result: 'draw', + tied: true, + needs_decider_match: true + ) + expect(GroupStageService.ranking_decision_for(score_b)).to include( + resolved_by: 'tie', + direct_comparison_result: 'draw', + tied: true, + needs_decider_match: true + ) + end + + it 'marks unresolved ties outside the advancing cutoff without requesting a decider' do + tournament.update!(playoff_teams_amount: 2, instant_finalists_amount: 2, intermediate_round_participants_amount: 0) + + score_a = create(:group_score, group: group, team: team_a, group_points: 9, scored_points: 5, received_points: 1, position: 1) + score_b = create(:group_score, group: group, team: team_b, group_points: 6, scored_points: 4, received_points: 2, position: 2) + 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: 3, scored_points: 2, received_points: 3) + + 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, 3]) + expect(GroupStageService.ranking_decision_for(score_c)).to include( + resolved_by: 'tie', + tied: true, + needs_decider_match: false + ) + expect(GroupStageService.ranking_decision_for(score_d)).to include( + resolved_by: 'tie', + tied: true, + needs_decider_match: false + ) + end + end + describe '#get_advancing_teams' do context 'when special case for po2 applies' do before do