Skip to content

Commit

Permalink
Assign teams to subsequent elimination rounds (#1001)
Browse files Browse the repository at this point in the history
fixes #998
  • Loading branch information
evroon authored Nov 8, 2024
1 parent ed659ff commit 6aa2c51
Show file tree
Hide file tree
Showing 24 changed files with 371 additions and 73 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,8 @@
from bracket.models.db.stage_item import StageType
from bracket.models.db.util import StageItemWithRounds
from bracket.sql.rankings import get_ranking_for_stage_item
from bracket.sql.stage_items import get_stage_item
from bracket.sql.teams import update_team_stats
from bracket.utils.id_types import PlayerId, StageItemId, StageItemInputId, TeamId, TournamentId
from bracket.utils.id_types import PlayerId, StageItemInputId, TeamId, TournamentId

K = 32
D = 400
Expand Down Expand Up @@ -104,12 +103,11 @@ def determine_team_ranking_for_stage_item(
return sorted(team_ranking.items(), key=lambda x: x[1].points, reverse=True)


async def recalculate_ranking_for_stage_item_id(
async def recalculate_ranking_for_stage_item(
tournament_id: TournamentId,
stage_item_id: StageItemId,
stage_item: StageItemWithRounds,
) -> None:
stage_item = await get_stage_item(tournament_id, stage_item_id)
ranking = await get_ranking_for_stage_item(tournament_id, stage_item_id)
ranking = await get_ranking_for_stage_item(tournament_id, stage_item.id)
assert stage_item, "Stage item not found"
assert ranking, "Ranking not found"

Expand Down
78 changes: 78 additions & 0 deletions backend/bracket/logic/ranking/elimination.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
from bracket.models.db.match import Match
from bracket.models.db.stage_item_inputs import StageItemInput
from bracket.models.db.util import StageItemWithRounds
from bracket.sql.matches import (
sql_set_input_ids_for_match,
)
from bracket.utils.id_types import (
MatchId,
RoundId,
)


def get_inputs_to_update_in_subsequent_elimination_rounds(
current_round_id: RoundId,
stage_item: StageItemWithRounds,
match_ids: set[MatchId],
) -> dict[MatchId, Match]:
"""
Determine the updates of stage item input IDs in the elimination tree.
Crucial aspect is that entering a winner for a match will influence matches of subsequent
rounds, because of the tree-like structure of elimination stage items.
"""
current_round = next(round_ for round_ in stage_item.rounds if round_.id == current_round_id)
affected_matches: dict[MatchId, Match] = {
match.id: match for match in current_round.matches if match.id in match_ids
}
subsequent_rounds = [round_ for round_ in stage_item.rounds if round_.id > current_round.id]
subsequent_rounds.sort(key=lambda round_: round_.id)
subsequent_matches = [match for round_ in subsequent_rounds for match in round_.matches]

for subsequent_match in subsequent_matches:
updated_inputs: list[StageItemInput | None] = [
subsequent_match.stage_item_input1,
subsequent_match.stage_item_input2,
]
original_inputs = updated_inputs.copy()

if subsequent_match.stage_item_input1_winner_from_match_id in affected_matches:
updated_inputs[0] = affected_matches[
subsequent_match.stage_item_input1_winner_from_match_id
].get_winner()

if subsequent_match.stage_item_input2_winner_from_match_id in affected_matches:
updated_inputs[1] = affected_matches[
subsequent_match.stage_item_input2_winner_from_match_id
].get_winner()

if original_inputs != updated_inputs:
input_ids = [input_.id if input_ else None for input_ in updated_inputs]

affected_matches[subsequent_match.id] = subsequent_match.model_copy(
update={
"stage_item_input1_id": input_ids[0],
"stage_item_input2_id": input_ids[1],
"stage_item_input1": updated_inputs[0],
"stage_item_input2": updated_inputs[1],
}
)

# All affected matches need to be updated except for the inputs.
return {
match_id: match for match_id, match in affected_matches.items() if match_id not in match_ids
}


async def update_inputs_in_subsequent_elimination_rounds(
current_round_id: RoundId,
stage_item: StageItemWithRounds,
match_ids: set[MatchId],
) -> None:
updates = get_inputs_to_update_in_subsequent_elimination_rounds(
current_round_id, stage_item, match_ids
)
for _, match in updates.items():
await sql_set_input_ids_for_match(
match.round_id, match.id, [match.stage_item_input1_id, match.stage_item_input2_id]
)
9 changes: 2 additions & 7 deletions backend/bracket/logic/scheduling/builder.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from fastapi import HTTPException

from bracket.logic.ranking.elo import recalculate_ranking_for_stage_item_id
from bracket.logic.ranking.calculation import recalculate_ranking_for_stage_item
from bracket.logic.scheduling.elimination import (
build_single_elimination_stage_item,
get_number_of_rounds_to_create_single_elimination,
Expand Down Expand Up @@ -54,11 +54,6 @@ async def build_matches_for_stage_item(stage_item: StageItem, tournament_id: Tou
await create_rounds_for_new_stage_item(tournament_id, stage_item)
stage_item_with_rounds = await get_stage_item(tournament_id, stage_item.id)

if stage_item_with_rounds is None:
raise ValueError(
f"Could not find stage item with id {stage_item.id} for tournament {tournament_id}"
)

match stage_item.type:
case StageType.ROUND_ROBIN:
await build_round_robin_stage_item(tournament_id, stage_item_with_rounds)
Expand All @@ -72,7 +67,7 @@ async def build_matches_for_stage_item(stage_item: StageItem, tournament_id: Tou
400, f"Cannot automatically create matches for stage type {stage_item.type}"
)

await recalculate_ranking_for_stage_item_id(tournament_id, stage_item.id)
await recalculate_ranking_for_stage_item(tournament_id, stage_item_with_rounds)


def determine_available_inputs(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from pydantic import BaseModel
from starlette import status

from bracket.logic.ranking.elo import (
from bracket.logic.ranking.calculation import (
determine_team_ranking_for_stage_item,
)
from bracket.logic.ranking.statistics import TeamStatistics
Expand Down
14 changes: 14 additions & 0 deletions backend/bracket/models/db/match.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,23 @@ class MatchInsertable(MatchBaseInsertable):

class Match(MatchInsertable):
id: MatchId
stage_item_input1: StageItemInput | None = None
stage_item_input2: StageItemInput | None = None

def get_winner(self) -> StageItemInput | None:
if self.stage_item_input1_score > self.stage_item_input2_score:
return self.stage_item_input1
if self.stage_item_input1_score < self.stage_item_input2_score:
return self.stage_item_input2

return None


class MatchWithDetails(Match):
"""
MatchWithDetails has zero or one defined stage item inputs, but not both.
"""

court: Court | None = None


Expand Down
17 changes: 13 additions & 4 deletions backend/bracket/routes/matches.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@
reorder_matches_for_court,
schedule_all_unscheduled_matches,
)
from bracket.logic.ranking.elo import (
recalculate_ranking_for_stage_item_id,
from bracket.logic.ranking.calculation import (
recalculate_ranking_for_stage_item,
)
from bracket.logic.ranking.elimination import update_inputs_in_subsequent_elimination_rounds
from bracket.logic.scheduling.upcoming_matches import (
get_draft_round_in_stage_item,
get_upcoming_matches_for_swiss,
Expand All @@ -23,13 +24,15 @@
MatchFilter,
MatchRescheduleBody,
)
from bracket.models.db.stage_item import StageType
from bracket.models.db.user import UserPublic
from bracket.routes.auth import user_authenticated_for_tournament
from bracket.routes.models import SingleMatchResponse, SuccessResponse, UpcomingMatchesResponse
from bracket.routes.util import match_dependency
from bracket.sql.courts import get_all_courts_in_tournament
from bracket.sql.matches import sql_create_match, sql_delete_match, sql_update_match
from bracket.sql.rounds import get_round_by_id
from bracket.sql.stage_items import get_stage_item
from bracket.sql.stages import get_full_tournament_details
from bracket.sql.tournaments import sql_get_tournament
from bracket.sql.validation import check_foreign_keys_belong_to_tournament
Expand Down Expand Up @@ -86,7 +89,9 @@ async def delete_match(

await sql_delete_match(match.id)

await recalculate_ranking_for_stage_item_id(tournament_id, round_.stage_item_id)
stage_item = await get_stage_item(tournament_id, round_.stage_item_id)

await recalculate_ranking_for_stage_item(tournament_id, stage_item)
return SuccessResponse()


Expand Down Expand Up @@ -156,7 +161,8 @@ async def update_match_by_id(
await sql_update_match(match_id, match_body, tournament)

round_ = await get_round_by_id(tournament_id, match.round_id)
await recalculate_ranking_for_stage_item_id(tournament_id, round_.stage_item_id)
stage_item = await get_stage_item(tournament_id, round_.stage_item_id)
await recalculate_ranking_for_stage_item(tournament_id, stage_item)

if (
match_body.custom_duration_minutes != match.custom_duration_minutes
Expand All @@ -166,4 +172,7 @@ async def update_match_by_id(
scheduled_matches = get_scheduled_matches(await get_full_tournament_details(tournament_id))
await reorder_matches_for_court(tournament, scheduled_matches, assert_some(match.court_id))

if stage_item.type == StageType.SINGLE_ELIMINATION:
await update_inputs_in_subsequent_elimination_rounds(round_.id, stage_item, {match_id})

return SuccessResponse()
6 changes: 4 additions & 2 deletions backend/bracket/routes/rankings.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends

from bracket.logic.ranking.elo import recalculate_ranking_for_stage_item_id
from bracket.logic.ranking.calculation import recalculate_ranking_for_stage_item
from bracket.logic.subscriptions import check_requirement
from bracket.models.db.ranking import RankingBody, RankingCreateBody
from bracket.models.db.user import UserPublic
Expand All @@ -19,6 +19,7 @@
sql_update_ranking,
)
from bracket.sql.stage_item_inputs import get_stage_item_input_ids_by_ranking_id
from bracket.sql.stage_items import get_stage_item
from bracket.utils.id_types import RankingId, TournamentId

router = APIRouter()
Expand Down Expand Up @@ -46,7 +47,8 @@ async def update_ranking_by_id(
)
stage_item_ids = await get_stage_item_input_ids_by_ranking_id(ranking_id)
for stage_item_id in stage_item_ids:
await recalculate_ranking_for_stage_item_id(tournament_id, stage_item_id)
stage_item = await get_stage_item(tournament_id, stage_item_id)
await recalculate_ranking_for_stage_item(tournament_id, stage_item)
return SuccessResponse()


Expand Down
14 changes: 5 additions & 9 deletions backend/bracket/routes/rounds.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
from starlette import status

from bracket.database import database
from bracket.logic.ranking.elo import (
recalculate_ranking_for_stage_item_id,
from bracket.logic.ranking.calculation import (
recalculate_ranking_for_stage_item,
)
from bracket.logic.subscriptions import check_requirement
from bracket.models.db.round import (
Expand Down Expand Up @@ -49,7 +49,9 @@ async def delete_round(
rounds.c.id == round_id and rounds.c.tournament_id == tournament_id
),
)
await recalculate_ranking_for_stage_item_id(tournament_id, round_with_matches.stage_item_id)

stage_item = await get_stage_item(tournament_id, round_with_matches.stage_item_id)
await recalculate_ranking_for_stage_item(tournament_id, stage_item)
return SuccessResponse()


Expand All @@ -72,12 +74,6 @@ async def create_round(

stage_item = await get_stage_item(tournament_id, stage_item_id=round_body.stage_item_id)

if stage_item is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Stage item doesn't exist",
)

if not stage_item.type.supports_dynamic_number_of_rounds:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
Expand Down
4 changes: 2 additions & 2 deletions backend/bracket/routes/stage_items.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
get_draft_round,
schedule_all_matches_for_swiss_round,
)
from bracket.logic.ranking.elo import recalculate_ranking_for_stage_item_id
from bracket.logic.ranking.calculation import recalculate_ranking_for_stage_item
from bracket.logic.scheduling.builder import (
build_matches_for_stage_item,
)
Expand Down Expand Up @@ -110,7 +110,7 @@ async def update_stage_item(
query=query,
values={"stage_item_id": stage_item_id, "name": stage_item_body.name},
)
await recalculate_ranking_for_stage_item_id(tournament_id, stage_item_id)
await recalculate_ranking_for_stage_item(tournament_id, stage_item)
return SuccessResponse()


Expand Down
10 changes: 1 addition & 9 deletions backend/bracket/routes/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,7 @@ async def stage_dependency(tournament_id: TournamentId, stage_id: StageId) -> St
async def stage_item_dependency(
tournament_id: TournamentId, stage_item_id: StageItemId
) -> StageItemWithRounds:
stage_item = await get_stage_item(tournament_id, stage_item_id=stage_item_id)

if stage_item is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Could not find stage item with id {stage_item_id}",
)

return stage_item
return await get_stage_item(tournament_id, stage_item_id=stage_item_id)


async def match_dependency(tournament_id: TournamentId, match_id: MatchId) -> Match:
Expand Down
30 changes: 29 additions & 1 deletion backend/bracket/sql/matches.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,14 @@
from bracket.database import database
from bracket.models.db.match import Match, MatchBody, MatchCreateBody
from bracket.models.db.tournament import Tournament
from bracket.utils.id_types import CourtId, MatchId, StageItemId, TournamentId
from bracket.utils.id_types import (
CourtId,
MatchId,
RoundId,
StageItemId,
StageItemInputId,
TournamentId,
)


async def sql_delete_match(match_id: MatchId) -> None:
Expand Down Expand Up @@ -111,6 +118,27 @@ async def sql_update_match(match_id: MatchId, match: MatchBody, tournament: Tour
)


async def sql_set_input_ids_for_match(
round_id: RoundId, match_id: MatchId, input_ids: list[StageItemInputId | None]
) -> None:
query = """
UPDATE matches
SET stage_item_input1_id = :input1_id,
stage_item_input2_id = :input2_id
WHERE round_id = :round_id
AND matches.id = :match_id
"""
await database.execute(
query=query,
values={
"round_id": round_id,
"match_id": match_id,
"input1_id": input_ids[0],
"input2_id": input_ids[1],
},
)


async def sql_reschedule_match(
match_id: MatchId,
court_id: CourtId | None,
Expand Down
6 changes: 0 additions & 6 deletions backend/bracket/sql/rounds.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,6 @@ async def get_rounds_for_stage_item(
tournament_id: TournamentId, stage_item_id: StageItemId
) -> list[RoundWithMatches]:
stage_item = await get_stage_item(tournament_id, stage_item_id)

if stage_item is None:
raise ValueError(
f"Could not find stage item with id {stage_item_id} for tournament {tournament_id}"
)

return stage_item.rounds


Expand Down
Loading

0 comments on commit 6aa2c51

Please sign in to comment.