Skip to content

Commit

Permalink
Implement custom time per match (#337)
Browse files Browse the repository at this point in the history
  • Loading branch information
evroon committed Nov 21, 2023
1 parent 4e616d8 commit 4b3dfb9
Show file tree
Hide file tree
Showing 39 changed files with 689 additions and 188 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""create custom match duration fields
Revision ID: 8bae62f80db7
Revises: d104afae31e9
Create Date: 2023-11-19 15:05:51.284093
"""

import sqlalchemy as sa

from alembic import op

# revision identifiers, used by Alembic.
revision: str | None = '8bae62f80db7'
down_revision: str | None = 'd104afae31e9'
branch_labels: str | None = None
depends_on: str | None = None


def upgrade() -> None:
op.add_column('matches', sa.Column('margin_minutes', sa.Integer(), nullable=True))
op.add_column('matches', sa.Column('custom_duration_minutes', sa.Integer(), nullable=True))
op.add_column('matches', sa.Column('custom_margin_minutes', sa.Integer(), nullable=True))
op.add_column(
'tournaments', sa.Column('margin_minutes', sa.Integer(), server_default='5', nullable=False)
)


def downgrade() -> None:
op.drop_column('tournaments', 'margin_minutes')
op.drop_column('matches', 'custom_margin_minutes')
op.drop_column('matches', 'custom_duration_minutes')
op.drop_column('matches', 'margin_minutes')
44 changes: 31 additions & 13 deletions backend/bracket/logic/planning/matches.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@
from bracket.models.db.tournament import Tournament
from bracket.models.db.util import StageWithStageItems
from bracket.sql.courts import get_all_courts_in_tournament
from bracket.sql.matches import sql_create_match, sql_reschedule_match
from bracket.sql.matches import (
sql_create_match,
sql_reschedule_match_and_determine_duration_and_margin,
)
from bracket.sql.stages import get_full_tournament_details
from bracket.sql.tournaments import sql_get_tournament
from bracket.utils.types import assert_some
Expand Down Expand Up @@ -45,11 +48,16 @@ async def schedule_all_unscheduled_matches(tournament_id: int) -> None:
for round_ in stage_item.rounds:
for match in round_.matches:
if match.start_time is None and match.position_in_schedule is None:
await sql_reschedule_match(
assert_some(match.id), court.id, start_time, position_in_schedule
await sql_reschedule_match_and_determine_duration_and_margin(
assert_some(match.id),
court.id,
start_time,
position_in_schedule,
match,
tournament,
)

start_time += timedelta(minutes=15)
start_time += timedelta(minutes=match.duration_minutes)
position_in_schedule += 1

for stage in stages[1:]:
Expand All @@ -58,12 +66,17 @@ async def schedule_all_unscheduled_matches(tournament_id: int) -> None:
for stage_item in stage.stage_items:
for round_ in stage_item.rounds:
for match in round_.matches:
start_time += timedelta(minutes=15)
start_time += timedelta(minutes=match.duration_minutes)
position_in_schedule += 1

if match.start_time is None and match.position_in_schedule is None:
await sql_reschedule_match(
assert_some(match.id), courts[-1].id, start_time, position_in_schedule
await sql_reschedule_match_and_determine_duration_and_margin(
assert_some(match.id),
courts[-1].id,
start_time,
position_in_schedule,
match,
tournament,
)


Expand Down Expand Up @@ -131,13 +144,14 @@ async def iterative_scheduling(
winner_from_match_id=match.team2_winner_from_match_id,
)
team_defs = {match.team1_id, match.team2_id}

court_id = sorted(match_count_per_court.items(), key=lambda x: x[1])[0][0]

try:
position_in_schedule = len(matches_per_court[court_id])
last_match = matches_per_court[court_id][-1]
start_time = assert_some(last_match.start_time) + timedelta(minutes=15)
start_time = assert_some(last_match.start_time) + timedelta(
minutes=match.duration_minutes
)
except IndexError:
start_time = tournament.start_time
position_in_schedule = 0
Expand All @@ -162,8 +176,8 @@ async def iterative_scheduling(
attempts_since_last_write = 0
random.shuffle(matches_to_schedule)

await sql_reschedule_match(
assert_some(match.id), court_id, start_time, position_in_schedule
await sql_reschedule_match_and_determine_duration_and_margin(
assert_some(match.id), court_id, start_time, position_in_schedule, match, tournament
)


Expand All @@ -184,13 +198,17 @@ async def reorder_matches_for_court(

last_start_time = tournament.start_time
for i, match_pos in enumerate(matches_this_court):
await sql_reschedule_match(
await sql_reschedule_match_and_determine_duration_and_margin(
assert_some(match_pos.match.id),
court_id,
last_start_time,
position_in_schedule=i,
match=match_pos.match,
tournament=tournament,
)
last_start_time = last_start_time + timedelta(
minutes=match_pos.match.duration_minutes + match_pos.match.margin_minutes
)
last_start_time = last_start_time + timedelta(minutes=15)


async def handle_match_reschedule(
Expand Down
76 changes: 67 additions & 9 deletions backend/bracket/logic/planning/rounds.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
from heliclockter import timedelta
from heliclockter import datetime_utc, timedelta

from bracket.logic.planning.matches import get_scheduled_matches_per_court
from bracket.models.db.util import RoundWithMatches, StageItemWithRounds
from bracket.sql.courts import get_all_courts_in_tournament
from bracket.sql.matches import sql_reschedule_match
from bracket.sql.matches import (
sql_reschedule_match_and_determine_duration_and_margin,
)
from bracket.sql.stages import get_full_tournament_details
from bracket.sql.tournaments import sql_get_tournament
from bracket.utils.types import assert_some


class MatchTimingAdjustmentInfeasible(Exception):
pass


def get_active_and_next_rounds(
stage_item: StageItemWithRounds,
) -> tuple[RoundWithMatches | None, RoundWithMatches | None]:
Expand All @@ -29,11 +36,13 @@ def is_round_in_future(round_: RoundWithMatches) -> bool:


async def schedule_all_matches_for_swiss_round(
tournament_id: int, active_round: RoundWithMatches
tournament_id: int, active_round: RoundWithMatches, adjust_to_time: datetime_utc | None
) -> None:
courts = await get_all_courts_in_tournament(tournament_id)
stages = await get_full_tournament_details(tournament_id)
tournament = await sql_get_tournament(tournament_id)
matches_per_court = get_scheduled_matches_per_court(stages)
rescheduling_operations = []

if len(courts) < 1:
return
Expand All @@ -42,11 +51,60 @@ async def schedule_all_matches_for_swiss_round(

for i, match in enumerate(active_round.matches):
court_id = assert_some(courts[i].id)
last_match = matches_per_court[court_id][-1]
last_match = (
next((m for m in matches_per_court[court_id][::-1] if m.match.id != match.id), None)
if court_id in matches_per_court
else None
)

if last_match is not None:
timing_difference_minutes = 0.0
if adjust_to_time is not None:
last_match_end = last_match.match.end_time
timing_difference_minutes = (adjust_to_time - last_match_end).total_seconds() // 60

await sql_reschedule_match(
assert_some(match.id),
court_id,
assert_some(last_match.match.start_time) + timedelta(minutes=15),
assert_some(last_match.match.position_in_schedule) + 1,
if (
timing_difference_minutes < 0
and -timing_difference_minutes > last_match.match.margin_minutes
):
raise MatchTimingAdjustmentInfeasible(
"A match from the previous round is still happening"
)

if timing_difference_minutes != 0:
last_match_adjusted = last_match.match.copy(
update={
'custom_margin_minutes': last_match.match.margin_minutes
+ timing_difference_minutes
}
)
rescheduling_operations.append(
sql_reschedule_match_and_determine_duration_and_margin(
assert_some(last_match.match.id),
court_id,
assert_some(last_match.match.start_time),
assert_some(last_match.match.position_in_schedule),
last_match_adjusted,
tournament,
)
)

start_time = assert_some(last_match.match.start_time) + timedelta(
minutes=match.duration_minutes
+ last_match.match.margin_minutes
+ timing_difference_minutes
)
pos_in_schedule = assert_some(last_match.match.position_in_schedule) + 1
else:
start_time = tournament.start_time
pos_in_schedule = 1

rescheduling_operations.append(
sql_reschedule_match_and_determine_duration_and_margin(
assert_some(match.id), court_id, start_time, pos_in_schedule, match, tournament
)
)

# TODO: if safe: await asyncio.gather(*rescheduling_operations)
for op in rescheduling_operations:
await op
19 changes: 16 additions & 3 deletions backend/bracket/logic/scheduling/elimination.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
from bracket.logic.planning.matches import create_match_and_assign_free_court
from bracket.models.db.match import Match, MatchCreateBody
from bracket.models.db.tournament import Tournament
from bracket.models.db.util import RoundWithMatches, StageItemWithRounds
from bracket.sql.rounds import get_rounds_for_stage_item
from bracket.sql.tournaments import sql_get_tournament
from bracket.utils.types import assert_some


def determine_matches_first_round(
round_: RoundWithMatches, stage_item: StageItemWithRounds
round_: RoundWithMatches, stage_item: StageItemWithRounds, tournament: Tournament
) -> list[MatchCreateBody]:
suggestions: list[MatchCreateBody] = []

Expand All @@ -25,6 +27,10 @@ def determine_matches_first_round(
team2_winner_from_stage_item_id=second_input.winner_from_stage_item_id,
team2_winner_position=second_input.winner_position,
team2_winner_from_match_id=second_input.winner_from_match_id,
duration_minutes=tournament.duration_minutes,
margin_minutes=tournament.margin_minutes,
custom_duration_minutes=None,
custom_margin_minutes=None,
)
)

Expand All @@ -34,6 +40,7 @@ def determine_matches_first_round(
def determine_matches_subsequent_round(
prev_matches: list[Match],
round_: RoundWithMatches,
tournament: Tournament,
) -> list[MatchCreateBody]:
suggestions: list[MatchCreateBody] = []

Expand All @@ -53,6 +60,10 @@ def determine_matches_subsequent_round(
team2_winner_position=None,
team1_winner_from_match_id=assert_some(first_match.id),
team2_winner_from_match_id=assert_some(second_match.id),
duration_minutes=tournament.duration_minutes,
margin_minutes=tournament.margin_minutes,
custom_duration_minutes=None,
custom_margin_minutes=None,
)
)
return suggestions
Expand All @@ -62,18 +73,20 @@ async def build_single_elimination_stage_item(
tournament_id: int, stage_item: StageItemWithRounds
) -> None:
rounds = await get_rounds_for_stage_item(tournament_id, stage_item.id)
tournament = await sql_get_tournament(tournament_id)

assert len(rounds) > 0
first_round = rounds[0]

prev_matches = [
await create_match_and_assign_free_court(tournament_id, match)
for match in determine_matches_first_round(first_round, stage_item)
for match in determine_matches_first_round(first_round, stage_item, tournament)
]

for round_ in rounds[1:]:
prev_matches = [
await create_match_and_assign_free_court(tournament_id, match)
for match in determine_matches_subsequent_round(prev_matches, round_)
for match in determine_matches_subsequent_round(prev_matches, round_, tournament)
]


Expand Down
4 changes: 3 additions & 1 deletion backend/bracket/logic/scheduling/ladder_teams.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ def get_possible_upcoming_matches_for_swiss(
raise HTTPException(400, 'There is no draft round, so no matches can be scheduled.')

draft_round_team_ids = get_draft_round_team_ids(draft_round)
teams_to_schedule = [team for team in teams if team.id not in draft_round_team_ids]
teams_to_schedule = [
team for team in teams if team.id not in draft_round_team_ids and team.active
]

if len(teams_to_schedule) < 1:
return suggestions
Expand Down
6 changes: 6 additions & 0 deletions backend/bracket/logic/scheduling/round_robin.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
MatchCreateBody,
)
from bracket.models.db.util import StageItemWithRounds
from bracket.sql.tournaments import sql_get_tournament
from bracket.utils.types import assert_some


Expand Down Expand Up @@ -36,6 +37,7 @@ def get_round_robin_combinations(team_count: int) -> list[list[tuple[int, int]]]

async def build_round_robin_stage_item(tournament_id: int, stage_item: StageItemWithRounds) -> None:
matches = get_round_robin_combinations(len(stage_item.inputs))
tournament = await sql_get_tournament(tournament_id)

for i, round_ in enumerate(stage_item.rounds):
for team_1_id, team_2_id in matches[i]:
Expand All @@ -53,6 +55,10 @@ async def build_round_robin_stage_item(tournament_id: int, stage_item: StageItem
team2_winner_position=team_2.winner_position,
team2_winner_from_match_id=team_2.winner_from_match_id,
court_id=None,
duration_minutes=tournament.duration_minutes,
margin_minutes=tournament.margin_minutes,
custom_duration_minutes=None,
custom_margin_minutes=None,
)
await create_match_and_assign_free_court(tournament_id, match)

Expand Down
21 changes: 16 additions & 5 deletions backend/bracket/models/db/match.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,21 @@ class MatchBase(BaseModelORM):
id: int | None = None
created: datetime_utc
start_time: datetime_utc | None
duration_minutes: int | None
duration_minutes: int
margin_minutes: int
custom_duration_minutes: int | None
custom_margin_minutes: int | None
position_in_schedule: int | None
round_id: int
team1_score: int
team2_score: int
court_id: int | None

@property
def end_time(self, default_minutes: int = 15) -> datetime_utc:
def end_time(self) -> datetime_utc:
assert self.start_time
return datetime_utc.from_datetime(
self.start_time
+ timedelta(minutes=self.duration_minutes if self.duration_minutes else default_minutes)
self.start_time + timedelta(minutes=self.duration_minutes + self.margin_minutes)
)


Expand Down Expand Up @@ -83,9 +85,11 @@ class MatchBody(BaseModelORM):
team1_score: int = 0
team2_score: int = 0
court_id: int | None
custom_duration_minutes: int | None
custom_margin_minutes: int | None


class MatchCreateBody(BaseModelORM):
class MatchCreateBodyFrontend(BaseModelORM):
round_id: int
court_id: int | None
team1_id: int | None
Expand All @@ -98,6 +102,13 @@ class MatchCreateBody(BaseModelORM):
team2_winner_from_match_id: int | None


class MatchCreateBody(MatchCreateBodyFrontend):
duration_minutes: int
margin_minutes: int
custom_duration_minutes: int | None
custom_margin_minutes: int | None


class MatchRescheduleBody(BaseModelORM):
old_court_id: int
old_position: int
Expand Down
Loading

1 comment on commit 4b3dfb9

@vercel
Copy link

@vercel vercel bot commented on 4b3dfb9 Nov 21, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.