Fix group score direct comparison tiebreak

This commit is contained in:
Daniel Schädler 2026-04-12 17:03:33 +02:00
parent ec18e7aff3
commit 7cef4a9b5e
5 changed files with 255 additions and 7 deletions

View File

@ -10,13 +10,23 @@ class GroupScore < ApplicationRecord
def <=>(other) def <=>(other)
point_comparison = [-group_points, -difference_in_points, -scored_points] <=> [-other.group_points, -other.difference_in_points, -other.scored_points] point_comparison = [-group_points, -difference_in_points, -scored_points] <=> [-other.group_points, -other.difference_in_points, -other.scored_points]
if point_comparison.zero? return point_comparison unless point_comparison.zero?
direct_comparison(other)
end
private
def direct_comparison(other)
comparison_match = group.matches.find do |match| comparison_match = group.matches.find do |match|
match.match_scores.any? { |match_score| match_score.team == team } match.finished? && match.teams.include?(team) && match.teams.include?(other.team)
end
comparison_match.scored_points_of(team) <=> comparison_match.scored_points_of(other.team)
else
point_comparison
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

@ -133,6 +133,30 @@ module TurniereE2E
result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at)
end end
def run_group_direct_comparison_tiebreak(case_name:, stop_at: :group_scores)
session = register_session!
tournament = create_tournament!(
create_group_stage_payload(group_count: 1, teams_per_group: 4, playoff_teams_amount: 2)
)
checkpoints = {
created: summarize_tournament(tournament)
}
return result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) if stop_at == :created
group = group_stage_of!(tournament).fetch(:groups).first
play_group_matches_with_explicit_scores!(group, group_direct_comparison_score_plan(case_name))
group = get!("/groups/#{group.fetch(:id)}")
checkpoints[:group_scores] = {
ordered_group_scores: ordered_group_scores(group),
team_positions: team_positions(group)
}
tournament = fetch_tournament!(tournament.fetch(:id))
result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at)
end
def wait_for_healthcheck!(timeout_seconds: 30) def wait_for_healthcheck!(timeout_seconds: 30)
started_at = Time.now started_at = Time.now
loop do loop do
@ -255,6 +279,34 @@ module TurniereE2E
} }
end end
def play_match_with_points!(match, points_by_team_name:, expected_finish_status: 200, expected_finish_error: nil)
started = client.patch("/matches/#{match.fetch(:id)}", body: { state: 'in_progress' })
expect_status!(started, 200, "start match #{match.fetch(:id)}")
refreshed_match = fetch_match!(match.fetch(:id))
refreshed_match.fetch(:match_scores).each do |score|
team_name = score.dig(:team, :name)
points = points_by_team_name.fetch(team_name) do
raise "missing score for #{team_name.inspect} in match #{match.fetch(:id)}"
end
updated_score = client.patch("/match_scores/#{score.fetch(:id)}", body: { points: points })
expect_status!(updated_score, 200, "update match_score #{score.fetch(:id)}")
end
finished = client.patch("/matches/#{match.fetch(:id)}", body: { state: 'finished' })
expect_status!(finished, expected_finish_status, "finish match #{match.fetch(:id)}")
if expected_finish_error
actual_error = finished.fetch(:json).fetch(:error)
raise "expected finish error #{expected_finish_error.inspect}, got #{actual_error.inspect}" unless actual_error == expected_finish_error
end
{
match_id: match.fetch(:id),
status: finished.fetch(:status),
body: finished.fetch(:json)
}
end
def fetch_match!(match_id) def fetch_match!(match_id)
response = client.get("/matches/#{match_id}") response = client.get("/matches/#{match_id}")
expect_status!(response, 200, "fetch match #{match_id}") expect_status!(response, 200, "fetch match #{match_id}")
@ -292,6 +344,20 @@ module TurniereE2E
.min_by { |name| [seed_identifier_for(name), name] } .min_by { |name| [seed_identifier_for(name), name] }
end end
def play_group_matches_with_explicit_scores!(group, score_plan)
matches_by_teams = group.fetch(:matches).each_with_object({}) do |match, memo|
team_names = match.fetch(:match_scores).map { |score| score.dig(:team, :name) }.sort
memo[team_names] = match
end
score_plan.each do |team_names, points_by_team_name|
match = matches_by_teams.fetch(team_names.sort) do
raise "match for #{team_names.inspect} not found"
end
play_match_with_points!(match, points_by_team_name: points_by_team_name)
end
end
def preferred_playoff_winner_name(match) def preferred_playoff_winner_name(match)
match.fetch(:match_scores) match.fetch(:match_scores)
.map { |score| score.dig(:team, :name) } .map { |score| score.dig(:team, :name) }
@ -335,6 +401,61 @@ module TurniereE2E
} }
end end
def ordered_group_scores(group)
group.fetch(:group_scores)
.sort_by { |score| [score.fetch(:position), score.dig(:team, :name)] }
.map do |score|
{
team_name: score.dig(:team, :name),
position: score.fetch(:position),
group_points: score.fetch(:group_points),
difference_in_points: score.fetch(:difference_in_points),
scored_points: score.fetch(:scored_points),
received_points: score.fetch(:received_points)
}
end
end
def team_positions(group)
group.fetch(:group_scores).each_with_object({}) do |score, memo|
memo[score.dig(:team, :name)] = score.fetch(:position)
end
end
def group_direct_comparison_score_plan(case_name)
case case_name.to_sym
when :won
{
%w[G1-S1 G1-S2] => { 'G1-S1' => 2, 'G1-S2' => 1 },
%w[G1-S1 G1-S3] => { 'G1-S1' => 3, 'G1-S3' => 0 },
%w[G1-S1 G1-S4] => { 'G1-S1' => 0, 'G1-S4' => 1 },
%w[G1-S2 G1-S3] => { 'G1-S2' => 2, 'G1-S3' => 0 },
%w[G1-S2 G1-S4] => { 'G1-S2' => 2, 'G1-S4' => 0 },
%w[G1-S3 G1-S4] => { 'G1-S3' => 1, 'G1-S4' => 0 }
}
when :lost
{
%w[G1-S1 G1-S2] => { 'G1-S1' => 1, 'G1-S2' => 2 },
%w[G1-S1 G1-S3] => { 'G1-S1' => 2, 'G1-S3' => 0 },
%w[G1-S1 G1-S4] => { 'G1-S1' => 2, 'G1-S4' => 0 },
%w[G1-S2 G1-S3] => { 'G1-S2' => 1, 'G1-S3' => 0 },
%w[G1-S2 G1-S4] => { 'G1-S2' => 2, 'G1-S4' => 1 },
%w[G1-S3 G1-S4] => { 'G1-S3' => 1, 'G1-S4' => 0 }
}
when :drawn
{
%w[G1-S1 G1-S2] => { 'G1-S1' => 1, 'G1-S2' => 1 },
%w[G1-S1 G1-S3] => { 'G1-S1' => 2, 'G1-S3' => 0 },
%w[G1-S1 G1-S4] => { 'G1-S1' => 1, 'G1-S4' => 0 },
%w[G1-S2 G1-S3] => { 'G1-S2' => 2, 'G1-S3' => 0 },
%w[G1-S2 G1-S4] => { 'G1-S2' => 1, 'G1-S4' => 0 },
%w[G1-S3 G1-S4] => { 'G1-S3' => 1, 'G1-S4' => 0 }
}
else
raise "unknown direct comparison case #{case_name.inspect}"
end
end
def summarize_tournament(tournament) def summarize_tournament(tournament)
{ {
id: tournament.fetch(:id), id: tournament.fetch(:id),

View File

@ -59,6 +59,62 @@ RSpec.describe 'Tournament lifecycle HTTP E2E' do
expect(final_attempt.dig(:body, :error)).to eq('Moving Team one stage down failed') expect(final_attempt.dig(:body, :error)).to eq('Moving Team one stage down failed')
end end
{
won: [1, 2],
lost: [2, 1],
drawn: [1, 1]
}.each do |direct_result, expected_positions|
it "applies the direct-comparison tiebreak over HTTP when team A #{direct_result} the head-to-head" do
result = runner.run_group_direct_comparison_tiebreak(case_name: direct_result)
positions = result.dig(:checkpoints, :group_scores, :team_positions)
ordered_scores = result.dig(:checkpoints, :group_scores, :ordered_group_scores)
expect(positions.fetch('G1-S1')).to eq(expected_positions[0])
expect(positions.fetch('G1-S2')).to eq(expected_positions[1])
if direct_result == :drawn
expect(ordered_scores[0].slice(:team_name, :position, :group_points, :difference_in_points, :scored_points, :received_points))
.to eq(
team_name: 'G1-S1',
position: 1,
group_points: 7,
difference_in_points: 3,
scored_points: 4,
received_points: 1
)
expect(ordered_scores[1].slice(:team_name, :position, :group_points, :difference_in_points, :scored_points, :received_points))
.to eq(
team_name: 'G1-S2',
position: 1,
group_points: 7,
difference_in_points: 3,
scored_points: 4,
received_points: 1
)
else
expect(ordered_scores[0].slice(:team_name, :position, :group_points, :difference_in_points, :scored_points, :received_points))
.to eq(
team_name: expected_positions[0] == 1 ? 'G1-S1' : 'G1-S2',
position: 1,
group_points: 6,
difference_in_points: 3,
scored_points: 5,
received_points: 2
)
expect(ordered_scores[1].slice(:team_name, :position, :group_points, :difference_in_points, :scored_points, :received_points))
.to eq(
team_name: expected_positions[0] == 1 ? 'G1-S2' : 'G1-S1',
position: 2,
group_points: 6,
difference_in_points: 3,
scored_points: 5,
received_points: 2
)
end
end
end
[2, 3, 5, 8].each do |team_count| [2, 3, 5, 8].each do |team_count|
it "drives a #{team_count}-team playoff-only tournament over HTTP to completion" do it "drives a #{team_count}-team playoff-only tournament over HTTP to completion" do
result = runner.run_playoff_only(team_count: team_count) result = runner.run_playoff_only(team_count: team_count)

View File

@ -7,4 +7,49 @@ RSpec.describe GroupScore, type: :model do
it { should belong_to :team } it { should belong_to :team }
it { should belong_to :group } it { should belong_to :group }
end end
describe '#<=>' do
let(:group) { create(:group, match_count: 0) }
let(:team_a) { create(:team) }
let(:team_b) { create(:team) }
let(:team_c) { 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: 6, scored_points: 7, received_points: 4)
end
it 'prefers the winner of the direct comparison when the first three criteria are tied' do
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_c, points: 1)
end
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
group_score_a.reload
group_score_b.reload
expect(group_score_a <=> group_score_b).to eq(-1)
expect(group_score_b <=> group_score_a).to eq(1)
end
it 'keeps the teams tied when their direct comparison was a draw' do
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
group_score_a.reload
group_score_b.reload
expect(group_score_a <=> group_score_b).to eq(0)
expect(group_score_b <=> group_score_a).to eq(0)
end
end
end end

View File

@ -191,6 +191,22 @@ RSpec.describe GroupStageService do
end end
end end
end end
it 'uses the direct comparison when the first three criteria are tied' do
group = create(:group, match_count: 0)
team_a = create(:team)
team_b = create(:team)
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
create(:group_score, group: group, team: team_a, group_points: 3, scored_points: 4, received_points: 2)
create(:group_score, group: group, team: team_b, group_points: 3, scored_points: 4, received_points: 2)
expect(GroupStageService.teams_sorted_by_group_scores(group.reload)).to eq([team_a, team_b])
end
end end
describe '#get_advancing_teams' do describe '#get_advancing_teams' do