Implement TUR-58 group stage decider matches

This commit is contained in:
Daniel Schädler 2026-04-12 19:13:52 +02:00
parent 84cdb230fb
commit 4713a020fa
19 changed files with 323 additions and 20 deletions

View File

@ -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

View File

@ -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

View File

@ -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?

View File

@ -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 direct_comparison_match_against(other)
group.matches.reload.find do |match|
match.finished? && match.teams.include?(team) && match.teams.include?(other.team)
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)
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

View File

@ -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

View File

@ -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

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
class MatchScoreSerializer < ApplicationSerializer
attributes :points
attributes :points, :hidden_points
belongs_to :team
end

View File

@ -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

View File

@ -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
#

View File

@ -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

View File

@ -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

View File

@ -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
)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 }

View File

@ -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

View File

@ -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

View File

@ -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