from __future__ import annotations from typing import Dict, List import random from warchron.constants import ContextType from warchron.model.exception import ( DomainError, ForbiddenOperation, RequiresConfirmation, ) from warchron.model.war import War from warchron.model.round import Round from warchron.model.battle import Battle from warchron.model.score_service import ScoreService class Pairing: @staticmethod def check_round_pairable( round: Round, ) -> None: if round.is_over: raise ForbiddenOperation("Can not resolve pairing on finished round") if round.has_finished_battle(): raise ForbiddenOperation("Can not resolve pairing with finished battle(s)") if len(round.battles) * 2 < len(round.choices): raise DomainError( "There are not enough sectors for all participants to battle" ) for pid, choice in round.choices.items(): if choice is not None and not choice.priority_sector_id: raise DomainError(f"Missing priority choice for participant {pid}") if choice is not None and not choice.secondary_sector_id: raise DomainError(f"Missing secondary choice for participant {pid}") def cleanup() -> None: for bat in round.battles.values(): bat.cleanup_battle_players() if any( bat.player_1_id is not None or bat.player_2_id is not None for bat in round.battles.values() ): raise RequiresConfirmation( "Battle(s) already have player(s) assigned for this round.\n" "Battle players will be cleared.\n" "Do you want to continue?", action=cleanup, ) @staticmethod def assign_battles_to_participants( war: War, round: Round, ) -> None: campaign = war.get_campaign_by_round(round.id) if campaign is None: raise DomainError(f"Campaign for round {round.id} doesn't exist") scores = ScoreService.compute_scores( war, ContextType.CAMPAIGN, campaign.id, ) score_groups = ScoreService.group_participants_by_score( scores, lambda score: score.victory_points ) sector_to_battle: Dict[str, Battle] = { b.sector_id: b for b in round.battles.values() } for group in score_groups: # persistent equality → random order ordered_group = list(group) random.shuffle(ordered_group) for participant_id in ordered_group: camp_part = campaign.get_campaign_participant_by_war_participant_id( participant_id ) if camp_part: Pairing._assign_single_participant( round, camp_part.id, sector_to_battle, ) @staticmethod def _assign_single_participant( round: Round, participant_id: str, sector_to_battle: Dict[str, Battle], ) -> None: choice = round.choices.get(participant_id) preferred_sectors: List[str] = [] if choice: if choice.priority_sector_id: preferred_sectors.append(choice.priority_sector_id) if choice.secondary_sector_id: preferred_sectors.append(choice.secondary_sector_id) # --- try preferred sectors --- for sect_id in preferred_sectors: battle = sector_to_battle.get(sect_id) if not battle: continue if battle.get_available_places(): battle.assign_participant(participant_id) return # --- fallback rules --- available_battles = round.get_battles_with_places() if not available_battles: raise RuntimeError("No available battle remaining") if len(available_battles) == 1: available_battles[0].assign_participant(participant_id) return # multiple remaining battles → warning raise RuntimeError( f"Ambiguous fallback for participant {participant_id}: " "multiple battles still available" )