From 31912853ba3e134eb9bb904d614218223b791308 Mon Sep 17 00:00:00 2001 From: Kyle Herndon Date: Wed, 17 Jun 2026 21:19:12 +0000 Subject: [PATCH] Read commissioner division ladder config --- .../common/ruleset_strategy/commissioner.py | 185 +++++++++++++++--- commissioners/common/utils.py | 94 ++++++++- .../ruleset_strategy_commissioner/README.md | 11 +- tests/test_commissioner_strategies.py | 117 ++++++++++- 4 files changed, 375 insertions(+), 32 deletions(-) diff --git a/commissioners/common/ruleset_strategy/commissioner.py b/commissioners/common/ruleset_strategy/commissioner.py index 985720a..bde3a15 100644 --- a/commissioners/common/ruleset_strategy/commissioner.py +++ b/commissioners/common/ruleset_strategy/commissioner.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Mapping, Sequence from datetime import UTC, datetime, timedelta from typing import Any @@ -42,6 +43,9 @@ _plural_word, _round_structure_description, _schedule_slot_description, + division_matches_selector, + select_competition_entry_division, + select_division_by_role, ) from commissioners.common.ruleset_strategy.config import RulesetStrategyCommissionerConfig, load_image_ruleset_strategy_config from commissioners.common.ruleset_strategy.entrants import division_entries, select_rule @@ -76,39 +80,36 @@ def league_migration_config(self, ctx: LeagueMigrationConfigContext) -> list[Div def migrate_league(self, ctx: LeagueMigrationContext) -> LeagueMigrationResult: configured_names = {division.name for division in self._config().migration_divisions} - competition = next((division for division in ctx.divisions if division.name == "Competition"), None) + desired_divisions = [division for division in ctx.divisions if division.name in configured_names] + fallback_competition = select_competition_entry_division(ctx.league.commissioner_config, desired_divisions) divisions_by_id = {division.id: division for division in ctx.divisions} + migration_rules = _legacy_division_migration_rules(ctx.league.commissioner_config) events: list[PolicyMembershipEventChange] = [] for membership in ctx.memberships: division = divisions_by_id.get(membership.division_id) if division is None or division.name in configured_names: continue - if division.name == "Dirt" and membership.status != "disqualified": - events.append( - PolicyMembershipEventChange( - league_policy_membership_id=membership.id, - from_division_id=membership.division_id, - to_division_id=None, - status="disqualified", - substatus=POLICY_MEMBERSHIP_SUBSTATUS_INACTIVE, - reason="Tournament restructure Dirt->Disqualified", - end_time=datetime.now(UTC), - evidence=[_legacy_division_migration_evidence(division.name, "Disqualified")], - ) - ) - elif division.name == "Wood" and competition is not None: - events.append( - PolicyMembershipEventChange( - league_policy_membership_id=membership.id, - from_division_id=membership.division_id, - to_division_id=competition.id, - status=_membership_status(membership.status), - substatus=membership.substatus, - reason=f"Tournament restructure Wood->{competition.name}", - evidence=[_legacy_division_migration_evidence(division.name, competition.name)], - ) - ) + rule = next( + ( + rule + for rule in migration_rules + if division_matches_selector(division, rule.get("from", rule.get("match"))) + ), + None, + ) + if rule is None: + continue + event = _legacy_division_migration_event( + rule, + membership=membership, + from_division=division, + target_divisions=desired_divisions, + commissioner_config=ctx.league.commissioner_config, + fallback_competition=fallback_competition, + ) + if event is not None: + events.append(event) return LeagueMigrationResult(policy_membership_events=events) def rank_division(self, ctx: DivisionLeaderboardContext) -> list[DivisionLeaderboardSnapshot]: @@ -283,6 +284,138 @@ def _membership_status(status: Any) -> str: return status.value if hasattr(status, "value") else str(status) +def _legacy_division_migration_rules(commissioner_config: Mapping[str, Any] | None) -> Sequence[Mapping[str, Any]]: + config = commissioner_config or {} + division_ladder = config.get("division_ladder") + rules = division_ladder.get("legacy_migrations") if isinstance(division_ladder, Mapping) else None + if isinstance(rules, Sequence) and not isinstance(rules, str): + return [rule for rule in rules if isinstance(rule, Mapping)] + return ( + { + "from": {"name": "Dirt"}, + "to": { + "status": "disqualified", + "substatus": POLICY_MEMBERSHIP_SUBSTATUS_INACTIVE, + "reason": "Tournament restructure Dirt->Disqualified", + "evidence_to": "Disqualified", + }, + }, + { + "from": {"name": "Wood"}, + "to": { + "role": "entry", + "reason": "Tournament restructure Wood->{target_division_name}", + }, + }, + ) + + +def _legacy_division_migration_event( + rule: Mapping[str, Any], + *, + membership: Any, + from_division: Any, + target_divisions: Sequence[Any], + commissioner_config: Mapping[str, Any] | None, + fallback_competition: Any | None, +) -> PolicyMembershipEventChange | None: + action = rule.get("to") + if not isinstance(action, Mapping): + return None + + target_division = _legacy_division_migration_target( + action, + commissioner_config, + target_divisions, + fallback_competition, + ) + status = action.get("status") + if status == "disqualified" and _membership_status(membership.status) == "disqualified": + return None + if target_division is None and status != "disqualified": + return None + + target_name = _legacy_division_migration_target_name(action, target_division) + return PolicyMembershipEventChange( + league_policy_membership_id=membership.id, + from_division_id=membership.division_id, + to_division_id=None if target_division is None else target_division.id, + status=str(status) if status is not None else _membership_status(membership.status), + substatus=action.get("substatus", membership.substatus), + reason=_legacy_division_migration_reason(action, from_division.name, target_name), + end_time=datetime.now(UTC) if status == "disqualified" else None, + evidence=[_legacy_division_migration_evidence(from_division.name, target_name)], + ) + + +def _legacy_division_migration_target( + action: Mapping[str, Any], + commissioner_config: Mapping[str, Any] | None, + target_divisions: Sequence[Any], + fallback_competition: Any | None, +) -> Any | None: + role = action.get("role", action.get("to_role")) + if isinstance(role, str): + target = select_division_by_role(commissioner_config, target_divisions, roles=(role,)) + if target is not None: + return target + if role in {"entry", "default", "competition"}: + return fallback_competition + + selector = ( + action.get("division") + or action.get("to_division") + or action.get("division_selector") + or _selector_from_flat_action(action) + ) + if selector is not None: + return next( + (division for division in target_divisions if division_matches_selector(division, selector)), + None, + ) + return None + + +def _selector_from_flat_action(action: Mapping[str, Any]) -> dict[str, Any] | None: + selector = { + selector_key: action[action_key] + for action_key, selector_key in ( + ("division_id", "id"), + ("to_division_id", "id"), + ("division_name", "name"), + ("to_division_name", "name"), + ("division_type", "type"), + ("to_division_type", "type"), + ("division_level", "level"), + ("to_division_level", "level"), + ) + if action_key in action + } + return selector or None + + +def _legacy_division_migration_target_name(action: Mapping[str, Any], target_division: Any | None) -> str: + if target_division is not None: + return str(target_division.name) + evidence_to = action.get("evidence_to") + if evidence_to is not None: + return str(evidence_to) + status = action.get("status") + if status is not None: + return str(status).title() + return "Removed" + + +def _legacy_division_migration_reason(action: Mapping[str, Any], from_name: str, target_name: str) -> str: + reason = action.get("reason") + if not isinstance(reason, str): + reason = "Tournament restructure {from_division_name}->{target_division_name}" + return reason.format( + from_division_name=from_name, + target_division_name=target_name, + ) + + def _legacy_division_migration_evidence(from_division: str, to_division: str) -> PolicyMembershipEventEvidence: return PolicyMembershipEventEvidence( type="tournament_restructure", diff --git a/commissioners/common/utils.py b/commissioners/common/utils.py index c5f5c16..88a0148 100644 --- a/commissioners/common/utils.py +++ b/commissioners/common/utils.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections import defaultdict +from collections.abc import Mapping, Sequence from datetime import UTC, datetime, timedelta from math import ceil from typing import Any @@ -36,6 +37,8 @@ ) AMONG_THEM_SCORE_KIND = MEAN_ROUND_SCORE_KIND AMONG_THEM_SCORING_MECHANICS = MEAN_SCORE_EWMA_SCORING_MECHANICS +QUALIFIER_DIVISION_ROLES = ("qualifier", "qualifiers") +COMPETITION_ENTRY_DIVISION_ROLES = ("entry", "default", "competition") def select_division( @@ -59,8 +62,11 @@ def select_qualifier_division( commissioner_config: dict[str, Any] | None, divisions: list[DivisionSnapshot], ) -> DivisionSnapshot | None: - from commissioners.common.models import DIVISION_TYPE_STAGING config = commissioner_config or {} + division = select_division_by_role(config, divisions, roles=QUALIFIER_DIVISION_ROLES) + if division is not None or has_division_role_config(config, QUALIFIER_DIVISION_ROLES): + return division + qualifiers_division_name = config.get("qualifiers_division_name") if not qualifiers_division_name: return None @@ -78,6 +84,10 @@ def select_competition_entry_division( ) -> DivisionSnapshot | None: from commissioners.common.models import DIVISION_TYPE_COMPETITION config = commissioner_config or {} + division = select_division_by_role(config, divisions, roles=COMPETITION_ENTRY_DIVISION_ROLES) + if division is not None or has_division_role_config(config, COMPETITION_ENTRY_DIVISION_ROLES): + return division + return select_division( divisions, division_name=config.get("default_division_name"), @@ -86,6 +96,88 @@ def select_competition_entry_division( ) +def select_division_by_role( + commissioner_config: Mapping[str, Any] | None, + divisions: Sequence[DivisionSnapshot], + *, + roles: Sequence[str], +) -> DivisionSnapshot | None: + config = commissioner_config or {} + return next( + (division for division in divisions if division_matches_any_role(division, config, roles)), + None, + ) + + +def has_division_role_config(commissioner_config: Mapping[str, Any] | None, roles: Sequence[str]) -> bool: + config = commissioner_config or {} + return any(_division_role_selector(config, role) is not None for role in roles) + + +def division_matches_any_role( + division: DivisionSnapshot, + commissioner_config: Mapping[str, Any], + roles: Sequence[str], +) -> bool: + return any( + division_matches_selector(division, _division_role_selector(commissioner_config, role)) for role in roles + ) + + +def _division_role_selector(config: Mapping[str, Any], role: str) -> Any: + division_roles = config.get("division_roles") + if isinstance(division_roles, Mapping) and role in division_roles: + return division_roles[role] + + division_ladder = config.get("division_ladder") + ladder_levels: Any = None + if isinstance(division_ladder, Mapping): + ladder_levels = division_ladder.get("levels") + elif isinstance(division_ladder, Sequence) and not isinstance(division_ladder, str): + ladder_levels = division_ladder + if isinstance(ladder_levels, Sequence) and not isinstance(ladder_levels, str): + for level in ladder_levels: + if isinstance(level, Mapping) and level.get("role") == role: + return level + return None + + +def division_matches_selector(division: DivisionSnapshot, selector: Any) -> bool: + if selector is None: + return False + if isinstance(selector, str): + return str(division.id) == selector + if not isinstance(selector, Mapping): + return False + + matched = False + division_id = selector.get("division_id", selector.get("id")) + if division_id is not None: + matched = True + if str(division.id) != str(division_id): + return False + + division_name = selector.get("division_name", selector.get("name")) + if division_name is not None: + matched = True + if division.name != division_name: + return False + + division_type = selector.get("division_type", selector.get("type")) + if division_type is not None: + matched = True + if division.type != division_type: + return False + + division_level = selector.get("division_level", selector.get("level")) + if division_level is not None: + matched = True + if division.level != division_level: + return False + + return matched + + def division_entrants( memberships: list[MembershipSnapshot], division: DivisionSnapshot, diff --git a/commissioners/ruleset_strategy_commissioner/README.md b/commissioners/ruleset_strategy_commissioner/README.md index bc95b9f..7bbc4ef 100644 --- a/commissioners/ruleset_strategy_commissioner/README.md +++ b/commissioners/ruleset_strategy_commissioner/README.md @@ -2,10 +2,13 @@ Configurable Coworld commissioner whose behavior is packaged in the container image. -The runnable does not read `league.commissioner_config` for behavior. That field is a platform wire artifact and may -contain legacy data while Coworlds roll over to container commissioners. Configs are authored in the readable -shape below, copied into the image, and selected by the image's `RULESET_STRATEGY_CONFIG_NAME` or -`RULESET_STRATEGY_CONFIG_PATH` environment variables. +The runnable gets its active scheduling, seating, and transition behavior from image YAML, not from +`league.commissioner_config`. That field is mostly a platform wire artifact and may contain legacy data while Coworlds +roll over to container commissioners. During league migration, the runnable may read +`division_roles`/`division_ladder.legacy_migrations` from `league.commissioner_config` to move memberships out of old +topology tiers without baking those display names into the image. Configs are authored in the readable shape below, +copied into the image, and selected by the image's `RULESET_STRATEGY_CONFIG_NAME` or `RULESET_STRATEGY_CONFIG_PATH` +environment variables. The shared Dockerfile bundles configs from `configs/` and defaults to `configs/default.yaml`. Build the default baseline image with: diff --git a/tests/test_commissioner_strategies.py b/tests/test_commissioner_strategies.py index c442e56..f243aa1 100644 --- a/tests/test_commissioner_strategies.py +++ b/tests/test_commissioner_strategies.py @@ -195,6 +195,80 @@ def test_ruleset_strategy_migration_moves_legacy_dirt_and_wood_memberships() -> assert events[wood_membership_id].substatus == "champion" +def test_ruleset_strategy_migration_uses_configured_legacy_ladder_rules() -> None: + league_id = uuid4() + retired_id = uuid4() + promoted_id = uuid4() + competition_id = uuid4() + retired_membership_id = uuid4() + promoted_membership_id = uuid4() + + response = migrate_league_for_request( + _ruleset_commissioner("default"), + LeagueMigrationRequest( + league=LeagueInfo( + id=league_id, + commissioner_config={ + "division_roles": {"entry": {"type": "competition", "level": 1}}, + "division_ladder": { + "legacy_migrations": [ + { + "from": {"name": "Mud", "type": "competition"}, + "to": { + "status": "disqualified", + "substatus": "inactive", + "reason": "Tournament restructure Mud->Disqualified", + "evidence_to": "Disqualified", + }, + }, + { + "from": {"name": "Tree", "type": "competition"}, + "to": { + "role": "entry", + "reason": "Tournament restructure Tree->{target_division_name}", + }, + }, + ], + }, + }, + ), + divisions=[ + DivisionInfo(id=retired_id, name="Mud", level=0, type="competition"), + DivisionInfo(id=promoted_id, name="Tree", level=1, type="competition"), + DivisionInfo(id=competition_id, name="Competition", level=1, type="competition"), + ], + memberships=[ + MembershipInfo( + id=retired_membership_id, + league_id=league_id, + division_id=retired_id, + policy_version_id=uuid4(), + status="competing", + ), + MembershipInfo( + id=promoted_membership_id, + league_id=league_id, + division_id=promoted_id, + policy_version_id=uuid4(), + status="competing", + substatus="champion", + is_champion=True, + ), + ], + ), + ) + + events = {event.league_policy_membership_id: event for event in response.policy_membership_events} + assert set(events) == {retired_membership_id, promoted_membership_id} + assert events[retired_membership_id].to_division_id is None + assert events[retired_membership_id].status == "disqualified" + assert events[retired_membership_id].substatus == "inactive" + assert events[promoted_membership_id].to_division_id == competition_id + assert events[promoted_membership_id].status == "competing" + assert events[promoted_membership_id].substatus == "champion" + assert events[promoted_membership_id].reason == "Tournament restructure Tree->Competition" + + def _round_start( *, policy_version_ids: list[UUID], @@ -202,6 +276,7 @@ def _round_start( commissioner_config: dict | None = None, division_name: str = "Bronze", division_id: UUID | None = None, + division_level: int = 0, division_type: str = "competition", extra_divisions: list[DivisionInfo] | None = None, state: dict | None = None, @@ -209,7 +284,7 @@ def _round_start( active_division_id = division_id or uuid4() league_id = uuid4() divisions = [ - DivisionInfo(id=active_division_id, name=division_name, level=0, type=division_type), + DivisionInfo(id=active_division_id, name=division_name, level=division_level, type=division_type), *(extra_divisions or []), ] # The helper's members are the active division's entrants: qualifying members in a @@ -2331,6 +2406,46 @@ def test_round_start_adapter_allows_non_champion_qualifier_entries() -> None: assert scheduled_policy_ids == set(qualifier_policy_ids) +def test_round_start_adapter_selects_renamed_qualifier_by_role() -> None: + qualifier_id = uuid4() + competition_id = uuid4() + qualifier_policy_ids = [uuid4(), uuid4()] + round_start = _round_start( + policy_version_ids=[], + num_agents=2, + commissioner_config={ + "division_roles": {"qualifier": {"type": "staging", "level": -99}}, + "minimum_champions": 2, + }, + division_name="Tryouts", + division_id=qualifier_id, + division_level=-99, + division_type="staging", + extra_divisions=[ + DivisionInfo(id=competition_id, name="Competition", level=1, type="competition"), + ], + state={"round_config": {"current_division_id": str(qualifier_id)}}, + ) + round_start.memberships.extend( + [ + MembershipInfo( + id=uuid4(), + league_id=round_start.league.id, + division_id=qualifier_id, + policy_version_id=policy_version_id, + player_id=f"qualifier-player-{index}", + status="qualifying", + ) + for index, policy_version_id in enumerate(qualifier_policy_ids) + ] + ) + + schedule = schedule_episodes_for_round_start(BaselineCommissioner(), round_start) + + scheduled_policy_ids = {policy_id for episode in schedule.episodes for policy_id in episode.policy_version_ids} + assert scheduled_policy_ids == set(qualifier_policy_ids) + + def test_round_start_adapter_requires_division_id_to_target_the_round_division() -> None: """Regression for the deployed bug, framed as a parity requirement.