Add ranking decision metadata for group scores

This commit is contained in:
Daniel Schädler 2026-04-12 17:47:28 +02:00
parent 7cef4a9b5e
commit eedafe1276
6 changed files with 328 additions and 13 deletions

View File

@ -4,29 +4,75 @@ class GroupScore < ApplicationRecord
belongs_to :team belongs_to :team
belongs_to :group belongs_to :group
COMPARISON_ATTRIBUTES = {
group_points: :group_points,
difference_in_points: :difference_in_points,
scored_points: :scored_points
}.freeze
def difference_in_points def difference_in_points
scored_points - received_points scored_points - received_points
end end
def <=>(other) def <=>(other)
point_comparison = [-group_points, -difference_in_points, -scored_points] <=> [-other.group_points, -other.difference_in_points, -other.scored_points] comparison_reason = comparison_reason_against(other)
return point_comparison unless point_comparison.zero?
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 end
private private
def direct_comparison(other) def direct_comparison_match_against(other)
comparison_match = group.matches.find do |match| group.matches.reload.find do |match|
match.finished? && match.teams.include?(team) && match.teams.include?(other.team) match.finished? && match.teams.include?(team) && match.teams.include?(other.team)
end 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
end end

View File

@ -1,7 +1,11 @@
# frozen_string_literal: true # frozen_string_literal: true
class GroupScoreSerializer < ApplicationSerializer 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 belongs_to :team
def ranking_decision
GroupStageService.ranking_decision_for(object)
end
end end

View File

@ -96,6 +96,28 @@ class GroupStageService
group.group_scores.sort.map(&:team) group.group_scores.sort.map(&:team)
end 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 # 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 # They are ordered in such a way, that PlayoffStageService will correctly match the teams
# #
@ -187,5 +209,56 @@ class GroupStageService
group_scores group_scores
end 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
end end

View File

@ -21,5 +21,45 @@ RSpec.describe GroupsController, type: :controller do
expect(body[:matches].size).to eq(@group.matches.size) expect(body[:matches].size).to eq(@group.matches.size)
expect(body[:group_scores].size).to eq(@group.group_scores.size) expect(body[:group_scores].size).to eq(@group.group_scores.size)
end 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
end end

View File

@ -52,4 +52,53 @@ RSpec.describe GroupScore, type: :model do
expect(group_score_b <=> group_score_a).to eq(0) expect(group_score_b <=> group_score_a).to eq(0)
end end
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 end

View File

@ -209,6 +209,109 @@ RSpec.describe GroupStageService do
end end
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 describe '#get_advancing_teams' do
context 'when special case for po2 applies' do context 'when special case for po2 applies' do
before do before do