Merge branch 'codex/TUR-45-third-place-match' into 'master'

TUR-45 add third-place playoff match

Closes TUR-45

See merge request turniere/turniere-backend!47
This commit is contained in:
Daniel Schädler 2026-04-22 14:36:27 +00:00
commit 201abe5145
6 changed files with 116 additions and 35 deletions

View File

@ -39,6 +39,13 @@ class Match < ApplicationRecord
finished? ? current_leading_team : nil
end
def loser
winning_team = winner
return nil if winning_team.nil?
teams.find { |team| team != winning_team }
end
def group_match?
group.present?
end

View File

@ -16,7 +16,7 @@ class PlayoffStageService
initial_stage.state = :intermediate_stage unless initial_stage.matches.find(&:single_team?).nil?
playoffs << initial_stage
# empty stages are the stages, the tournament is filled with to have the matches ready for later
empty_stages = generate_stages_with_empty_matches(stage_count - 1)
empty_stages = generate_stages_with_empty_matches(stage_count - 1, teams.size > 3)
playoffs.concat empty_stages
playoffs
end
@ -25,10 +25,12 @@ class PlayoffStageService
#
# @param stage_count [Integer] number of stages to generate
# @return [Array] the generated stages
def generate_stages_with_empty_matches(stage_count)
def generate_stages_with_empty_matches(stage_count, include_third_place_match = false)
empty_stages = []
stage_count.times do |i|
stage = Stage.new level: i, matches: generate_empty_matches(2**i)
match_count = 2**i
match_count += 1 if include_third_place_match && i.zero?
stage = Stage.new level: i, matches: generate_empty_matches(match_count)
empty_stages << stage
end
# as we are generating the stages in the wrong order (starting with the lowest number of matches (which is
@ -76,33 +78,14 @@ class PlayoffStageService
# return if next stage does not exist (there are no matches after the finale)
return [] if next_stage.nil?
current_position = current_match.position
# a "companion" match is the one that with the selected match makes up the two matches
# of which the winners advance into the match below
# depending on the position of the match, the companion match is either on the left or right of it
companion_match = find_companion_match(current_position, current_stage)
match_below = next_stage.matches.find { |m| m.position == current_position / 2 }
match_scores = match_below.match_scores.sort_by(&:id)
companion_match = find_companion_match(current_match.position, current_stage)
return populate_final_stage_matches(current_match, companion_match, next_stage) if semifinal_match?(current_stage, next_stage)
winners = get_winners_of(companion_match, current_match)
# depending on the amount of match_scores already present we need to do different things
match_scores = assign_correct_match_scores!(match_scores, winners)
# If a match is not decided yet, it will return nil as winner.
# This is not allowed in Database. The following code filters out MatchScores that contain nil as team.
match_scores = match_scores.select { |ms| ms.team.present? }
match_below.match_scores = match_scores
match_below.state = if match_below.match_scores.empty? || match_below.match_scores.size == 1
:not_ready
elsif match_below.match_scores.size == 2
:not_started
else
raise 'Unprocessable amount of match_scores found'
end
[match_below, match_scores].flatten
populate_match!(match: next_stage.matches.find { |m| m.position == current_match.position / 2 }, teams: winners)
end
private
@ -135,9 +118,43 @@ class PlayoffStageService
match_scores
end
def populate_match!(match:, teams:)
match_scores = match.match_scores.sort_by(&:id)
match_scores = assign_correct_match_scores!(match_scores, teams)
match_scores = match_scores.select { |ms| ms.team.present? }
match.match_scores = match_scores
match.state = if match.match_scores.empty? || match.match_scores.size == 1
:not_ready
elsif match.match_scores.size == 2
:not_started
else
raise 'Unprocessable amount of match_scores found'
end
[match, match_scores].flatten
end
def get_winners_of(companion_match, current_match)
matches = [current_match, companion_match].sort_by(&:position)
matches.map(&:winner)
end
def get_losers_of(companion_match, current_match)
matches = [current_match, companion_match].sort_by(&:position)
matches.map(&:loser)
end
def semifinal_match?(current_stage, next_stage)
current_stage.level == 1 && next_stage.level.zero? && next_stage.matches.size > 1
end
def populate_final_stage_matches(current_match, companion_match, next_stage)
final_match = next_stage.matches.find { |match| match.position.zero? }
third_place_match = next_stage.matches.find { |match| match.position == 1 }
[
populate_match!(match: final_match, teams: get_winners_of(companion_match, current_match)),
populate_match!(match: third_place_match, teams: get_losers_of(companion_match, current_match))
].flatten
end
end
end

View File

@ -64,8 +64,7 @@ module TurniereE2E
checkpoints[:before_final_finish_attempt] = summarize_tournament(tournament)
return result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) if stop_at == :before_final_finish_attempt
final_match = final_match_of!(tournament)
finish_attempt = play_match!(final_match, winner_name: preferred_playoff_winner_name(final_match))
finish_attempt = finish_final_stage_matches!(tournament)
tournament = fetch_tournament!(tournament.fetch(:id))
checkpoints[:final_finish_attempted] = {
finish_attempt: finish_attempt,
@ -97,8 +96,7 @@ module TurniereE2E
checkpoints[:before_final_finish_attempt] = summarize_tournament(tournament)
return result(session: session, tournament: tournament, checkpoints: checkpoints, stop_at: stop_at) if stop_at == :before_final_finish_attempt
final_match = final_match_of!(tournament)
finish_attempt = play_match!(final_match, winner_name: preferred_playoff_winner_name(final_match))
finish_attempt = finish_final_stage_matches!(tournament)
tournament = fetch_tournament!(tournament.fetch(:id))
checkpoints[:final_finish_attempted] = {
finish_attempt: finish_attempt,
@ -340,9 +338,24 @@ module TurniereE2E
tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 } || raise('group stage not found')
end
def final_match_of!(tournament)
def final_stage_matches_of!(tournament)
final_stage = tournament.fetch(:stages).find { |stage| stage.fetch(:level) == 0 } || raise('final stage not found')
attach_stage_level(final_stage).fetch(:matches).first
attach_stage_level(final_stage).fetch(:matches).sort_by { |match| match.fetch(:position) }
end
def final_match_of!(tournament)
final_stage_matches_of!(tournament).first
end
def finish_final_stage_matches!(tournament)
final_stage_matches_of!(tournament).each do |match|
play_match!(match, winner_name: preferred_playoff_winner_name(match))
end
{
status: 200,
finished_match_ids: final_stage_matches_of!(fetch_tournament!(tournament.fetch(:id))).map { |match| match.fetch(:id) }
}
end
def playoff_matches(tournament)

View File

@ -37,7 +37,7 @@ RSpec.describe 'Tournament lifecycle HTTP E2E' do
final_attempt = result.dig(:checkpoints, :final_finish_attempted, :finish_attempt)
expect(final_attempt[:status]).to eq(200)
expect(result.dig(:checkpoints, :final_finish_attempted, :tournament, :stage_summaries)
.find { |stage| stage[:level] == 0 }[:match_states]).to eq(['finished'])
.find { |stage| stage[:level] == 0 }[:match_states]).to eq(%w[finished finished])
end
it 'drives the 3-group intermediate-round path over HTTP through a finished final' do
@ -58,7 +58,7 @@ RSpec.describe 'Tournament lifecycle HTTP E2E' do
final_attempt = result.dig(:checkpoints, :final_finish_attempted, :finish_attempt)
expect(final_attempt[:status]).to eq(200)
expect(result.dig(:checkpoints, :final_finish_attempted, :tournament, :stage_summaries)
.find { |stage| stage[:level] == 0 }[:match_states]).to eq(['finished'])
.find { |stage| stage[:level] == 0 }[:match_states]).to eq(%w[finished finished])
end
{
@ -126,7 +126,7 @@ RSpec.describe 'Tournament lifecycle HTTP E2E' do
completed_stages = result.dig(:checkpoints, :completed, :stage_summaries)
final_stage = completed_stages.find { |stage| stage[:level] == 0 }
expect(final_stage[:match_states]).to eq(['finished'])
expect(final_stage[:match_states]).to eq(team_count > 3 ? %w[finished finished] : ['finished'])
end
end
end

View File

@ -75,6 +75,13 @@ RSpec.describe PlayoffStageService do
expect(stage.level).to eq stage_level
end
end
it "generates correct amount of final-stage matches for #{parameters[:team_size]} teams" do
teams = build_list(:team, parameters[:team_size])
stages = PlayoffStageService.generate_playoff_stages(teams, false)
expect(stages.find { |stage| stage.level.zero? }.matches.size).to eq(parameters[:team_size] > 3 ? 2 : 1)
end
end
describe 'number of teams isn\'t a power of two' do
@ -229,5 +236,42 @@ RSpec.describe PlayoffStageService do
expect(PlayoffStageService.populate_match_below(@final_match)).to eq([])
end
end
context 'when semifinal finishes in tournament with third-place match' do
before do
@tournament = create(:stageless_tournament, teams_count: 4)
@tournament.stages = PlayoffStageService.generate_playoff_stages(@tournament.teams, false)
@tournament.save!
semifinal_stage = @tournament.stages.find { |stage| stage.level == 1 }
@semifinal_match = semifinal_stage.matches.find { |match| match.position.zero? }
@companion_match = semifinal_stage.matches.find { |match| match.position == 1 }
[@semifinal_match, @companion_match].each do |match|
match.update!(state: :finished)
match.match_scores.sort_by(&:id).each_with_index do |match_score, index|
match_score.update!(points: index.zero? ? 2 : 1)
end
end
@result = PlayoffStageService.populate_match_below(@semifinal_match)
@final_stage = @tournament.stages.find { |stage| stage.level.zero? }
@final_match = @final_stage.matches.find { |match| match.position.zero? }
@third_place_match = @final_stage.matches.find { |match| match.position == 1 }
end
it 'fills final with winners' do
expect(@final_match.teams).to match_array([@semifinal_match.winner, @companion_match.winner])
expect(@final_match.state).to eq('not_started')
end
it 'fills third-place match with losers' do
expect(@third_place_match.teams).to match_array([@semifinal_match.loser, @companion_match.loser])
expect(@third_place_match.state).to eq('not_started')
end
it 'returns both changed matches' do
expect(@result).to include(@final_match, @third_place_match)
end
end
end
end

View File

@ -56,7 +56,7 @@ RSpec.describe TournamentSnapshotImporter do
expect(sync_signature(follower)).to eq(sync_signature(source))
expect(follower.teams.count).to eq(128)
expect(follower.stages.where(level: -1).first.groups.count).to eq(32)
expect(follower.stages.where('level >= 0').sum { |stage| stage.matches.count }).to eq(63)
expect(follower.stages.where('level >= 0').sum { |stage| stage.matches.count }).to eq(64)
expect(follower.matches.select(&:decider?).count).to eq(source.matches.select(&:decider?).count)
expect(follower.teams.pluck(:sync_source_id)).to match_array(source.teams.pluck(:id))
expect(source.teams.pluck(:id) & follower.teams.pluck(:id)).not_to match_array(source.teams.pluck(:id))