From 2f952b4b68133c8bcd58731fcbdbb80749e7b388 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sat, 17 Jan 2026 22:18:43 +0000 Subject: [PATCH 01/11] First pass at adding support for match holding & releasing This implements the core logic around match states, though is untested. There's no support here yet for making edits. --- sr/comp/comp.py | 8 +- sr/comp/match_operations.py | 147 ++++++++++++++++++++++++++++++++++++ sr/comp/types.py | 37 +++++++++ 3 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 sr/comp/match_operations.py diff --git a/sr/comp/comp.py b/sr/comp/comp.py index b434d16..2916a1e 100644 --- a/sr/comp/comp.py +++ b/sr/comp/comp.py @@ -10,7 +10,7 @@ from subprocess import check_output from typing import cast -from . import arenas, matches, ranker, scores, teams, venue +from . import arenas, match_operations, matches, ranker, scores, teams, venue from .types import RankerType, ScorerType from .winners import compute_awards @@ -128,6 +128,12 @@ def __init__(self, root: str | Path) -> None: ) """A :class:`sr.comp.matches.MatchSchedule` instance.""" + self.operations = match_operations.MatchOperations.create( + self.root / 'operations.yaml', + self.schedule, + ) + """A :class:`sr.comp.match_operations.MatchOperations` instance.""" + self.timezone = self.schedule.timezone """The timezone of the competition.""" diff --git a/sr/comp/match_operations.py b/sr/comp/match_operations.py new file mode 100644 index 0000000..dbcb90f --- /dev/null +++ b/sr/comp/match_operations.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +import dataclasses +import datetime +import enum +from pathlib import Path + +from . import yaml_loader +from .match_period import Match +from .matches import MatchSchedule +from .types import MatchNumber, OperationsData, ReleasedMatchData + + +class MatchState(enum.Enum): + """ + The state of a match from the perspective of match operations. + + - Matches are initially all `FUTURE`. + - Once a match is released it will become `RELEASED`. + - If the current time is past the release threshold for a given match and it + has not be released, then it is `HELD`. + """ + + FUTURE = 'future' + HELD = 'held' + RELEASED = 'released' + + +@dataclasses.dataclass(frozen=True) +class ArenaTimes: + release_threshold: datetime.datetime + start: datetime.datetime + end: datetime.datetime + + +class InvalidResetDurationError(ValueError): + def __init__( + self, + release_threshold: datetime.timedelta, + reset_duration: datetime.timedelta, + ) -> None: + super().__init__(release_threshold, reset_duration) + self.release_threshold = release_threshold + self.reset_duration = reset_duration + + def __str__(self) -> str: + return ( + "Match reset duration must be at least as long as the release " + f"threshold. (threshold: {self.release_threshold}, " + f"reset duration: {self.reset_duration})" + ) + + +class InvalidReleasedMatchNumberError(ValueError): + def __init__( + self, + number: MatchNumber, + final_number: MatchNumber, + ) -> None: + super().__init__(number, final_number) + self.number = number + self.final_number = final_number + + def __str__(self) -> str: + return ( + f"Invalid released match number {self.number}, must be in range " + f"0-{self.final_number}" + ) + + +class MatchOperations: + @staticmethod + def create(path: Path, schedule: MatchSchedule) -> MatchOperations: + try: + y = yaml_loader.load(path) + operations_data: OperationsData = y['operations'] + + release_threshold = datetime.timedelta( + seconds=operations_data['release_threshold'], + ) + reset_duration = datetime.timedelta( + seconds=operations_data['reset_duration'], + ) + released_match = operations_data['released_match'] + + return MatchOperations( + schedule, + release_threshold=release_threshold, + reset_duration=reset_duration, + released_match=released_match, + ) + except FileNotFoundError: + final_match = schedule.final_match + return MatchOperations( + schedule, + release_threshold=datetime.timedelta(0), + reset_duration=datetime.timedelta(0), + released_match={ + 'number': final_match.num, + 'time': final_match.start_time, + }, + ) + + def __init__( + self, + schedule: MatchSchedule, + release_threshold: datetime.timedelta, + reset_duration: datetime.timedelta, + released_match: ReleasedMatchData | None, + ) -> None: + if reset_duration < release_threshold: + raise InvalidResetDurationError( + release_threshold=release_threshold, + reset_duration=reset_duration, + ) + + if released_match: + if released_match['number'] not in range(schedule.n_matches()): + raise InvalidReleasedMatchNumberError( + number=released_match['number'], + final_number=schedule.final_match.num, + ) + + self.schedule = schedule + self.release_threshold = release_threshold + self.reset_duration = reset_duration + self.released_match = released_match + + def get_arena_times(self, match: Match) -> ArenaTimes: + match_start = match.start_time + self.schedule.match_slot_lengths['pre'] + return ArenaTimes( + release_threshold=match_start - self.release_threshold, + start=match_start, + end=match_start + self.schedule.match_slot_lengths['match'], + ) + + def get_match_state(self, match: Match) -> MatchState: + if self.released_match and match.num <= self.released_match['number']: + # TODO: emit a warning if a released match slot hasn't started yet? + # Perhaps a "validation" warning? + return MatchState.RELEASED + + times = self.get_arena_times(match) + if times.release_threshold <= self.schedule.datetime_now: + return MatchState.HELD + + return MatchState.FUTURE diff --git a/sr/comp/types.py b/sr/comp/types.py index 3ebd2bf..bb7901b 100644 --- a/sr/comp/types.py +++ b/sr/comp/types.py @@ -179,3 +179,40 @@ class DelayData(TypedDict): AwardsData = NewType('AwardsData', dict[str, Union[TLA, list[TLA]]]) + + +class ReleasedMatchData(TypedDict): + number: MatchNumber + time: datetime.datetime + + +class OperationsData(TypedDict): + """ + Information relating to the operation of matches. + """ + + release_threshold: int + """ + Duration prior to the start of a match to pause if the match has not been + "released". In seconds relative to the start of the game (not the slot). + """ + + reset_duration: int + """ + Duration prior to the start of a match to reset to if the match is "reset". + In seconds relative to the start of the game (not the slot). Must be greater + than or equal to `release_threshold`. + """ + + released_match: ReleasedMatchData | None + """ + Information about the currently released match. + + Either: + - None, meaning that no matches have been released, or + - the most recently released match & when it was released + + History is not recorded. Delays are used to correct for "late" releases. + Resets are performed by changing this to a suitable value for the previous + match and (if needed) adding a delay. + """ From 4fad8f3b6ff781e1c04a13f029b949de3a708d3c Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sun, 18 Jan 2026 15:53:16 +0000 Subject: [PATCH 02/11] Introduce a helper to determine the 'current' matches This is largely copied over from srcomp-http in order to centralise the logic and enable easier changes. --- sr/comp/match_operations.py | 49 +++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/sr/comp/match_operations.py b/sr/comp/match_operations.py index dbcb90f..0b98e1c 100644 --- a/sr/comp/match_operations.py +++ b/sr/comp/match_operations.py @@ -3,6 +3,7 @@ import dataclasses import datetime import enum +from collections.abc import Collection from pathlib import Path from . import yaml_loader @@ -33,6 +34,14 @@ class ArenaTimes: end: datetime.datetime +@dataclasses.dataclass(frozen=True) +class CurrentMatches: + time: datetime.datetime + matches: Collection[Match] + staging_matches: Collection[Match] + shepherding_matches: Collection[Match] + + class InvalidResetDurationError(ValueError): def __init__( self, @@ -145,3 +154,43 @@ def get_match_state(self, match: Match) -> MatchState: return MatchState.HELD return MatchState.FUTURE + + def get_current_matches(self, when: datetime.datetime) -> CurrentMatches: + """ + Get all the matches with a useful relation to the current time. + + The time being passed in should always be the current time, however a + specific value may be passed to support cases where a single timestamp + is used for several separate queries against the compstate. + """ + + matches = [] + staging_matches = [] + shepherding_matches = [] + + for slot in self.schedule.matches: + for match in slot.values(): + if match.start_time <= when < match.end_time: + matches.append(match) + + staging_times = self.schedule.get_staging_times(match) + + if when > staging_times['closes']: + # Already done staging + continue + + if staging_times['opens'] <= when: + staging_matches.append(match) + + signal_shepherds = staging_times['signal_shepherds'] + if signal_shepherds: + first_signal = min(signal_shepherds.values()) + if first_signal <= when: + shepherding_matches.append(match) + + return CurrentMatches( + time=when, + matches=matches, + staging_matches=staging_matches, + shepherding_matches=shepherding_matches, + ) From 01ae39d46134367ab719df05d5f5333d856dc807 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sun, 18 Jan 2026 16:24:12 +0000 Subject: [PATCH 03/11] Clarify that this is not a Match instance --- sr/comp/match_operations.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sr/comp/match_operations.py b/sr/comp/match_operations.py index 0b98e1c..14c450f 100644 --- a/sr/comp/match_operations.py +++ b/sr/comp/match_operations.py @@ -133,7 +133,7 @@ def __init__( self.schedule = schedule self.release_threshold = release_threshold self.reset_duration = reset_duration - self.released_match = released_match + self.released_match_data = released_match def get_arena_times(self, match: Match) -> ArenaTimes: match_start = match.start_time + self.schedule.match_slot_lengths['pre'] @@ -144,7 +144,7 @@ def get_arena_times(self, match: Match) -> ArenaTimes: ) def get_match_state(self, match: Match) -> MatchState: - if self.released_match and match.num <= self.released_match['number']: + if self.released_match_data and match.num <= self.released_match_data['number']: # TODO: emit a warning if a released match slot hasn't started yet? # Perhaps a "validation" warning? return MatchState.RELEASED From 6d3e7535f6f2c933171b30133f7bca5abe98f331 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sun, 18 Jan 2026 16:24:28 +0000 Subject: [PATCH 04/11] Follow our pattern of allowing passing in a specific time While *mostly* we want this to be the current time, there are cases where we want to freeze the current time for several requests. This is particularly the case in srcomp-http for example. --- sr/comp/match_operations.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sr/comp/match_operations.py b/sr/comp/match_operations.py index 14c450f..74844b3 100644 --- a/sr/comp/match_operations.py +++ b/sr/comp/match_operations.py @@ -143,14 +143,14 @@ def get_arena_times(self, match: Match) -> ArenaTimes: end=match_start + self.schedule.match_slot_lengths['match'], ) - def get_match_state(self, match: Match) -> MatchState: + def get_match_state(self, match: Match, when: datetime.datetime) -> MatchState: if self.released_match_data and match.num <= self.released_match_data['number']: # TODO: emit a warning if a released match slot hasn't started yet? # Perhaps a "validation" warning? return MatchState.RELEASED times = self.get_arena_times(match) - if times.release_threshold <= self.schedule.datetime_now: + if times.release_threshold <= when: return MatchState.HELD return MatchState.FUTURE From 624d0216957ee34f275636d418218f48a54ee000 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sun, 18 Jan 2026 16:28:17 +0000 Subject: [PATCH 05/11] Account for operational holds when getting the current matches This introduces (horror!) the idea of an "effective time" which advances only as far as the next unreleased match's release threshold. --- sr/comp/match_operations.py | 40 ++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/sr/comp/match_operations.py b/sr/comp/match_operations.py index 74844b3..219e84c 100644 --- a/sr/comp/match_operations.py +++ b/sr/comp/match_operations.py @@ -155,15 +155,53 @@ def get_match_state(self, match: Match, when: datetime.datetime) -> MatchState: return MatchState.FUTURE + def _get_effective_time(self, when: datetime.datetime) -> datetime.datetime: + """ + Get the "effective" time for a given wall-clock time. + + The returned value accounts for any unreleased matches and can be safely + used with queries against the schedule (which is otherwise unaware of + operationally driven changes). + """ + + # For the next, yet to be released match + num = ( + self.released_match_data['number'] + 1 + if self.released_match_data + else 0 + ) + + if num >= self.schedule.n_matches(): + # All matches have been released + return when + + slot = self.schedule.matches[num] + match = next(iter(slot.values())) + + times = self.get_arena_times(match) + if times.release_threshold > when: + # Haven't reached the threshold yet -- all is well + return when + + # In a held state, things are effectively paused at the release + # threshold time + return times.release_threshold + def get_current_matches(self, when: datetime.datetime) -> CurrentMatches: """ Get all the matches with a useful relation to the current time. + This accounts for both delays committed to the schedule and ongoing + operational changes such as non-released matches. + The time being passed in should always be the current time, however a specific value may be passed to support cases where a single timestamp is used for several separate queries against the compstate. """ + real_when = when + when = self._get_effective_time(when) + matches = [] staging_matches = [] shepherding_matches = [] @@ -189,7 +227,7 @@ def get_current_matches(self, when: datetime.datetime) -> CurrentMatches: shepherding_matches.append(match) return CurrentMatches( - time=when, + time=real_when, matches=matches, staging_matches=staging_matches, shepherding_matches=shepherding_matches, From ae16d860dc2a324a0db93ab19e2348f96449d722 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sun, 18 Jan 2026 16:35:18 +0000 Subject: [PATCH 06/11] Drop the idea that these are 'current' matches There isn't really any need for that to be the case, even if that's the intended use-case. Certainly nothing about the logic actually relies on that being the case. --- sr/comp/match_operations.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/sr/comp/match_operations.py b/sr/comp/match_operations.py index 219e84c..0890d7d 100644 --- a/sr/comp/match_operations.py +++ b/sr/comp/match_operations.py @@ -35,7 +35,7 @@ class ArenaTimes: @dataclasses.dataclass(frozen=True) -class CurrentMatches: +class OperationsMatches: time: datetime.datetime matches: Collection[Match] staging_matches: Collection[Match] @@ -187,16 +187,12 @@ def _get_effective_time(self, when: datetime.datetime) -> datetime.datetime: # threshold time return times.release_threshold - def get_current_matches(self, when: datetime.datetime) -> CurrentMatches: + def get_matches_at(self, when: datetime.datetime) -> OperationsMatches: """ - Get all the matches with a useful relation to the current time. + Get all the matches with a useful relation to a given time. This accounts for both delays committed to the schedule and ongoing operational changes such as non-released matches. - - The time being passed in should always be the current time, however a - specific value may be passed to support cases where a single timestamp - is used for several separate queries against the compstate. """ real_when = when @@ -226,7 +222,7 @@ def get_current_matches(self, when: datetime.datetime) -> CurrentMatches: if first_signal <= when: shepherding_matches.append(match) - return CurrentMatches( + return OperationsMatches( time=real_when, matches=matches, staging_matches=staging_matches, From ae293c541f39519b50b4145d47d546856bb04d8d Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sun, 18 Jan 2026 16:42:11 +0000 Subject: [PATCH 07/11] Deprecate potentially misleading `MatchSchedule.matches_at` Now that we have operational considerations on top of the schedule, this would return a misleading view of the matches at a given time. If it turns out that we need a view of the originally scheduled matches at a given time we can reintroduce it, though with a clearer name (likely `matches_scheduled_at`). --- setup.py | 2 +- sr/comp/matches.py | 15 +++++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/setup.py b/setup.py index 6d18857..c93a961 100644 --- a/setup.py +++ b/setup.py @@ -23,7 +23,7 @@ 'PyYAML >=5.1.2, <7', 'league-ranker >=0.1, <2', 'python-dateutil >=2.7, <3', - 'typing-extensions >=4, <5', + 'typing-extensions >=4.5, <5', ], python_requires='>=3.10', classifiers=[ diff --git a/sr/comp/matches.py b/sr/comp/matches.py index 9d61355..ea956b6 100644 --- a/sr/comp/matches.py +++ b/sr/comp/matches.py @@ -6,7 +6,7 @@ from collections.abc import Iterable, Iterator, Mapping, Sequence from pathlib import Path from typing import Any, TypeVar -from typing_extensions import TypedDict +from typing_extensions import deprecated, TypedDict import dateutil.tz from league_ranker import RankedPosition @@ -423,12 +423,19 @@ def delay_at(self, date: datetime.datetime) -> datetime.timedelta: return total + @deprecated("Use SRComp.operations.get_matches_at instead.") def matches_at(self, date: datetime.datetime) -> Iterator[Match]: """ - Get all the matches that occur around a specific ``date``. + Deprecated in favour of ``MatchOperations.get_matches_at``. - :param datetime date: The date at which matches occur. - :return: An iterable list of matches. + Get all the matches scheduled to occur around a specific ``date``. + + This accounts for delays committed to the schedule, but does not account + for ongoing operational changes such as non-released matches. + + All known consumers want the latter to be included and should use + ``MatchOperations.get_matches_at`` instead. If usages which need + this behaviour are found, please report them. """ for slot in self.matches: From aa5ff3ae71f8ea273e075a363911bde5b84a8c63 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sun, 18 Jan 2026 17:00:00 +0000 Subject: [PATCH 08/11] Remove a TODO I don't think we need With how this is currently implenented (especially in the opt-out case) there isn't really a way to have this validation. It's also unclear whether or not this is useful. --- sr/comp/match_operations.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/sr/comp/match_operations.py b/sr/comp/match_operations.py index 0890d7d..8ac4627 100644 --- a/sr/comp/match_operations.py +++ b/sr/comp/match_operations.py @@ -145,8 +145,6 @@ def get_arena_times(self, match: Match) -> ArenaTimes: def get_match_state(self, match: Match, when: datetime.datetime) -> MatchState: if self.released_match_data and match.num <= self.released_match_data['number']: - # TODO: emit a warning if a released match slot hasn't started yet? - # Perhaps a "validation" warning? return MatchState.RELEASED times = self.get_arena_times(match) From bd9aa94f352de5d310e73bc77fc50a34f391afda Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sun, 18 Jan 2026 17:21:41 +0000 Subject: [PATCH 09/11] Bump typing-extensions to work around https://github.com/python/typing_extensions/issues/243 Not strictly something we need, however since we validate our minimum dependencies actually work this is needed for that to pass. --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index c93a961..d49a2cc 100644 --- a/setup.py +++ b/setup.py @@ -23,7 +23,7 @@ 'PyYAML >=5.1.2, <7', 'league-ranker >=0.1, <2', 'python-dateutil >=2.7, <3', - 'typing-extensions >=4.5, <5', + 'typing-extensions >=4.6, <5', ], python_requires='>=3.10', classifiers=[ From d4be6f33042d328e134cad25ba79180f7ec24232 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sun, 1 Feb 2026 13:22:32 +0000 Subject: [PATCH 10/11] Rename parameter for clarity --- sr/comp/match_operations.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/sr/comp/match_operations.py b/sr/comp/match_operations.py index 8ac4627..0f95c43 100644 --- a/sr/comp/match_operations.py +++ b/sr/comp/match_operations.py @@ -96,7 +96,7 @@ def create(path: Path, schedule: MatchSchedule) -> MatchOperations: schedule, release_threshold=release_threshold, reset_duration=reset_duration, - released_match=released_match, + released_match_data=released_match, ) except FileNotFoundError: final_match = schedule.final_match @@ -104,7 +104,7 @@ def create(path: Path, schedule: MatchSchedule) -> MatchOperations: schedule, release_threshold=datetime.timedelta(0), reset_duration=datetime.timedelta(0), - released_match={ + released_match_data={ 'number': final_match.num, 'time': final_match.start_time, }, @@ -115,7 +115,7 @@ def __init__( schedule: MatchSchedule, release_threshold: datetime.timedelta, reset_duration: datetime.timedelta, - released_match: ReleasedMatchData | None, + released_match_data: ReleasedMatchData | None, ) -> None: if reset_duration < release_threshold: raise InvalidResetDurationError( @@ -123,17 +123,17 @@ def __init__( reset_duration=reset_duration, ) - if released_match: - if released_match['number'] not in range(schedule.n_matches()): + if released_match_data: + if released_match_data['number'] not in range(schedule.n_matches()): raise InvalidReleasedMatchNumberError( - number=released_match['number'], + number=released_match_data['number'], final_number=schedule.final_match.num, ) self.schedule = schedule self.release_threshold = release_threshold self.reset_duration = reset_duration - self.released_match_data = released_match + self.released_match_data = released_match_data def get_arena_times(self, match: Match) -> ArenaTimes: match_start = match.start_time + self.schedule.match_slot_lengths['pre'] From 9dfcea54f073101da73e8fb62c8e4b108c118f1b Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sun, 1 Feb 2026 13:25:43 +0000 Subject: [PATCH 11/11] Expose the most recently released match --- sr/comp/match_operations.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/sr/comp/match_operations.py b/sr/comp/match_operations.py index 0f95c43..dcf412d 100644 --- a/sr/comp/match_operations.py +++ b/sr/comp/match_operations.py @@ -135,6 +135,13 @@ def __init__( self.reset_duration = reset_duration self.released_match_data = released_match_data + @property + def last_released_match(self) -> MatchNumber | None: + """The most recently released match.""" + if not self.released_match_data: + return None + return self.released_match_data['number'] + def get_arena_times(self, match: Match) -> ArenaTimes: match_start = match.start_time + self.schedule.match_slot_lengths['pre'] return ArenaTimes( @@ -144,7 +151,8 @@ def get_arena_times(self, match: Match) -> ArenaTimes: ) def get_match_state(self, match: Match, when: datetime.datetime) -> MatchState: - if self.released_match_data and match.num <= self.released_match_data['number']: + last_released_match = self.last_released_match + if last_released_match is not None and match.num <= last_released_match: return MatchState.RELEASED times = self.get_arena_times(match)