diff --git a/src/warchron/controller/app_controller.py b/src/warchron/controller/app_controller.py index 276f73b..1548839 100644 --- a/src/warchron/controller/app_controller.py +++ b/src/warchron/controller/app_controller.py @@ -111,7 +111,15 @@ class AppController: path = self.view.ask_open_file() if not path: return - self.model.load(path) + try: + self.model.load(path) + except RuntimeError as e: + QMessageBox.warning( + self.view, + "Add forbidden", + str(e), + ) + return self.current_file = path self.is_dirty = False self.navigation.refresh_players_view() diff --git a/src/warchron/controller/campaign_controller.py b/src/warchron/controller/campaign_controller.py index e3736fb..2477378 100644 --- a/src/warchron/controller/campaign_controller.py +++ b/src/warchron/controller/campaign_controller.py @@ -14,7 +14,7 @@ from warchron.controller.dtos import ( RoundDTO, CampaignParticipantScoreDTO, ) -from warchron.model.exception import ForbiddenOperation, DomainError +from warchron.model.exception import AbortedOperation, DomainError from warchron.model.war import War from warchron.model.campaign import Campaign from warchron.model.campaign_participant import CampaignParticipant @@ -196,7 +196,7 @@ class CampaignController: ) if not dialog.exec(): TieResolver.cancel_tie_break(war, ctx) - raise ForbiddenOperation("Tie resolution cancelled") + raise AbortedOperation("Tie resolution cancelled") bids_map[ctx.key()] = dialog.get_bids() return bids_map diff --git a/src/warchron/controller/closure_workflow.py b/src/warchron/controller/closure_workflow.py index 75100d3..ba02870 100644 --- a/src/warchron/controller/closure_workflow.py +++ b/src/warchron/controller/closure_workflow.py @@ -104,12 +104,8 @@ class RoundPairingWorkflow: def start(self, war: War, round: Round) -> None: Pairing.check_round_pairable(round) - ties = TieResolver.find_choice_ties(war, round.id) - while ties: - bids_map = self.app.rounds.resolve_ties(war, ties) - for tie in ties: - bids = bids_map[tie.key()] - TieResolver.apply_bids(war, tie, bids) - TieResolver.resolve_tie_state(war, tie, bids) - ties = TieResolver.find_choice_ties(war, round.id) - Pairing.assign_battles_to_participants(war, round) + Pairing.assign_battles_to_participants( + war, + round, + resolve_ties_callback=self.app.rounds.resolve_ties, + ) diff --git a/src/warchron/controller/round_controller.py b/src/warchron/controller/round_controller.py index beeac04..aeb86a2 100644 --- a/src/warchron/controller/round_controller.py +++ b/src/warchron/controller/round_controller.py @@ -6,7 +6,7 @@ from PyQt6.QtGui import QIcon from warchron.constants import ItemType, RefreshScope, Icons, IconName, ContextType from warchron.model.exception import ( - ForbiddenOperation, + AbortedOperation, DomainError, RequiresConfirmation, ) @@ -119,9 +119,7 @@ class RoundController: ) p1_war = None if battle.player_1_id is not None: - p1_war = camp.participants[ - battle.player_1_id - ].war_participant_id + p1_war = camp.campaign_to_war_part_id(battle.player_1_id) pixmap = Icons.get_pixmap(IconName.TIEBREAK_TOKEN) if effective_winner == p1_war: p1_icon = QIcon(pixmap) @@ -192,10 +190,19 @@ class RoundController: workflow = RoundPairingWorkflow(self.app) try: workflow.start(war, rnd) + except AbortedOperation as e: + QMessageBox.warning( + self.app.view, + "Canceled pairing", + str(e), + ) + for bat in rnd.battles.values(): + bat.cleanup_battle_players() + return except DomainError as e: QMessageBox.warning( self.app.view, - "Closure forbidden", + "Pairing impossible", str(e), ) return @@ -238,7 +245,7 @@ class RoundController: ) if not dialog.exec(): TieResolver.cancel_tie_break(war, ctx) - raise ForbiddenOperation("Tie resolution cancelled") + raise AbortedOperation("Tie resolution cancelled") bids_map[ctx.key()] = dialog.get_bids() return bids_map diff --git a/src/warchron/controller/war_controller.py b/src/warchron/controller/war_controller.py index 0a73daa..8f065ad 100644 --- a/src/warchron/controller/war_controller.py +++ b/src/warchron/controller/war_controller.py @@ -10,7 +10,7 @@ from warchron.constants import ( ) from warchron.model.exception import ( DomainError, - ForbiddenOperation, + AbortedOperation, RequiresConfirmation, ) @@ -190,7 +190,7 @@ class WarController: ) if not dialog.exec(): TieResolver.cancel_tie_break(war, ctx) - raise ForbiddenOperation("Tie resolution cancelled") + raise AbortedOperation("Tie resolution cancelled") bids_map[ctx.key()] = dialog.get_bids() return bids_map diff --git a/src/warchron/model/battle.py b/src/warchron/model/battle.py index 17fb4cf..a34a3b3 100644 --- a/src/warchron/model/battle.py +++ b/src/warchron/model/battle.py @@ -1,6 +1,7 @@ from __future__ import annotations from typing import Any, Dict, List +from warchron.model.exception import DomainError from warchron.model.json_helper import JsonHelper @@ -70,7 +71,7 @@ class Battle: if self.player_2_id is None: self.player_2_id = participant_id return - raise RuntimeError("Battle has no available places") + raise DomainError("Battle has no available places") def cleanup_battle_players(self) -> None: self.player_1_id = None diff --git a/src/warchron/model/campaign.py b/src/warchron/model/campaign.py index c5004aa..9f88f5b 100644 --- a/src/warchron/model/campaign.py +++ b/src/warchron/model/campaign.py @@ -91,14 +91,21 @@ class Campaign: except KeyError: raise KeyError(f"Participant {participant_id} not in campaign {self.id}") - def get_campaign_participant_by_war_participant_id( + def campaign_to_war_part_id( self, - war_participant_id: str, - ) -> CampaignParticipant | None: + campaign_pid: str, + ) -> str: + cp = self.get_campaign_participant(campaign_pid) + return cp.war_participant_id + + def war_to_campaign_part_id( + self, + war_pid: str, + ) -> str: for cp in self.participants.values(): - if cp.war_participant_id == war_participant_id: - return cp - return None + if cp.war_participant_id == war_pid: + return cp.id + raise KeyError(f"War participant {war_pid} not within campaign participants") def get_all_campaign_participants(self) -> List[CampaignParticipant]: return list(self.participants.values()) diff --git a/src/warchron/model/closure_service.py b/src/warchron/model/closure_service.py index 0cc16f3..c943060 100644 --- a/src/warchron/model/closure_service.py +++ b/src/warchron/model/closure_service.py @@ -34,7 +34,7 @@ class ClosureService: return base_winner = None if battle.winner_id is not None: - base_winner = campaign.participants[battle.winner_id].war_participant_id + base_winner = campaign.campaign_to_war_part_id(battle.winner_id) effective_winner = ResultChecker.get_effective_winner_id( war, ContextType.BATTLE, diff --git a/src/warchron/model/exception.py b/src/warchron/model/exception.py index 0c6c38e..26194e3 100644 --- a/src/warchron/model/exception.py +++ b/src/warchron/model/exception.py @@ -13,6 +13,12 @@ class ForbiddenOperation(DomainError): pass +class AbortedOperation(DomainError): + """Generic 'you canceled this' rule.""" + + pass + + class DomainDecision(Exception): """Base class for domain actions requiring user decision.""" diff --git a/src/warchron/model/pairing.py b/src/warchron/model/pairing.py index 218b757..4ec0641 100644 --- a/src/warchron/model/pairing.py +++ b/src/warchron/model/pairing.py @@ -1,8 +1,8 @@ from __future__ import annotations -from typing import Dict, List +from typing import Dict, List, Callable, Tuple import random -from warchron.constants import ContextType +from warchron.constants import ContextType, ScoreKind from warchron.model.exception import ( DomainError, ForbiddenOperation, @@ -12,6 +12,14 @@ 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 +from warchron.model.tie_manager import TieResolver, TieContext +from warchron.model.war_event import TieResolved +from warchron.model.score_service import ParticipantScore + +ResolveTiesCallback = Callable[ + ["War", List["TieContext"]], + Dict[Tuple[str, str, int | None], Dict[str, bool]], +] class Pairing: @@ -30,13 +38,18 @@ class Pairing: ) 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}") + raise ForbiddenOperation( + 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}") + raise ForbiddenOperation( + f"Missing secondary choice for participant {pid}" + ) def cleanup() -> None: for bat in round.battles.values(): bat.cleanup_battle_players() + # FIXME cancel TieResolved + TokenSpent if any( bat.player_1_id is not None or bat.player_2_id is not None @@ -53,6 +66,7 @@ class Pairing: def assign_battles_to_participants( war: War, round: Round, + resolve_ties_callback: ResolveTiesCallback, ) -> None: campaign = war.get_campaign_by_round(round.id) if campaign is None: @@ -62,57 +76,162 @@ class Pairing: ContextType.CAMPAIGN, campaign.id, ) - score_groups = ScoreService.group_participants_by_score( - scores, lambda score: score.victory_points - ) + + def value_getter(score: ParticipantScore) -> int: + return score.victory_points + + score_groups = ScoreService.group_participants_by_score(scores, value_getter) 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, - ) + score_value = value_getter(scores[group[0]]) + remaining: List[str] = [ + campaign.war_to_campaign_part_id(pid) for pid in group + ] + Pairing._run_phase( + war, + round, + remaining, + sector_to_battle, + resolve_ties_callback, + use_priority=True, + score_value=score_value, + ) + Pairing._run_phase( + war, + round, + remaining, + sector_to_battle, + resolve_ties_callback, + use_priority=False, + score_value=score_value, + ) + Pairing._assign_fallback(round, remaining) @staticmethod - def _assign_single_participant( + def _run_phase( + war: War, round: Round, - participant_id: str, + remaining: List[str], sector_to_battle: Dict[str, Battle], + resolve_ties_callback: ResolveTiesCallback, + *, + use_priority: bool, + score_value: int, ) -> 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) + demand = Pairing._build_sector_demand( + round, + remaining, + use_priority, + ) + for sector_id, participants in demand.items(): + battle = sector_to_battle.get(sector_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" + places = len(battle.get_available_places()) + if places <= 0: + continue + winners = Pairing._resolve_sector_allocation( + war, + round, + sector_id, + participants, + places, + resolve_ties_callback, + score_value, + ) + for pid in winners: + battle.assign_participant(pid) + remaining.remove(pid) + + @staticmethod + def _build_sector_demand( + round: Round, + participants: List[str], + use_priority: bool, + ) -> Dict[str, List[str]]: + demand: Dict[str, List[str]] = {} + for pid in participants: + choice = round.choices.get(pid) + if not choice: + continue + sector_id = ( + choice.priority_sector_id + if use_priority + else choice.secondary_sector_id + ) + if not sector_id: + continue + demand.setdefault(sector_id, []).append(pid) + return demand + + @staticmethod + def _resolve_sector_allocation( + war: War, + round: Round, + sector_id: str, + participants: List[str], + places: int, + resolve_ties_callback: ResolveTiesCallback, + score_value: int, + ) -> List[str]: + if len(participants) <= places: + return participants + campaign = war.get_campaign_by_round(round.id) + if campaign is None: + raise DomainError("Campaign not found for round {round.id}") + context = TieContext( + context_type=ContextType.CHOICE, + context_id=round.id, + participants=[ + campaign.campaign_to_war_part_id(pid) for pid in participants + ], + score_kind=ScoreKind.VP, + sector_id=sector_id, ) + # ---- resolve tie loop ---- + while not TieResolver.is_tie_resolved(war, context): + if not TieResolver.can_tie_be_resolved(war, context, context.participants): + war.events.append( + TieResolved( + None, + context.context_type, + context.context_id, + score_value=score_value, + sector_id=sector_id, + ) + ) + break + bids_map = resolve_ties_callback(war, [context]) + bids = bids_map[context.key()] + TieResolver.apply_bids(war, context, bids) + TieResolver.resolve_tie_state(war, context, bids) + ranked_groups = TieResolver.rank_by_tokens( + war, + context, + context.participants, + ) + ordered: List[str] = [] + for group in ranked_groups: + shuffled_group = list(group) + random.shuffle(shuffled_group) + ordered.extend( + campaign.war_to_campaign_part_id(pid) for pid in shuffled_group + ) + return ordered[:places] + + @staticmethod + def _assign_fallback( + round: Round, + remaining: List[str], + ) -> None: + for pid in list(remaining): + available = round.get_battles_with_places() + if not available: + raise DomainError("No available battle remaining") + if len(available) == 1: + available[0].assign_participant(pid) + remaining.remove(pid) + continue + raise DomainError(f"Ambiguous fallback for participant {pid}") diff --git a/src/warchron/model/score_service.py b/src/warchron/model/score_service.py index aad36d9..f061729 100644 --- a/src/warchron/model/score_service.py +++ b/src/warchron/model/score_service.py @@ -56,9 +56,7 @@ class ScoreService: if battle.winner_id is not None: campaign = war.get_campaign_by_campaign_participant(battle.winner_id) if campaign is not None: - base_winner = campaign.participants[ - battle.winner_id - ].war_participant_id + base_winner = campaign.campaign_to_war_part_id(battle.winner_id) winner = ResultChecker.get_effective_winner_id( war, ContextType.BATTLE, battle.sector_id, base_winner ) diff --git a/src/warchron/model/tie_manager.py b/src/warchron/model/tie_manager.py index b32be90..47ca576 100644 --- a/src/warchron/model/tie_manager.py +++ b/src/warchron/model/tie_manager.py @@ -3,7 +3,7 @@ from dataclasses import dataclass, field from collections import defaultdict from warchron.constants import ContextType, ScoreKind -from warchron.model.exception import ForbiddenOperation +from warchron.model.exception import ForbiddenOperation, DomainError from warchron.model.war import War from warchron.model.war_event import InfluenceSpent, TieResolved from warchron.model.score_service import ScoreService, ParticipantScore @@ -17,6 +17,7 @@ class TieContext: score_value: int | None = None score_kind: ScoreKind | None = None objective_id: str | None = None + sector_id: str | None = None def key(self) -> tuple[str, str, int | None]: return (self.context_type, self.context_id, self.score_value) @@ -24,71 +25,6 @@ class TieContext: class TieResolver: - @staticmethod - def find_choice_ties( - war: War, - round_id: str, - ) -> List[TieContext]: - round = war.get_round(round_id) - campaign = war.get_campaign_by_round(round_id) - if campaign is None: - raise RuntimeError("Round without campaign") - ties: List[TieContext] = [] - 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 = {b.sector_id: b for b in round.battles.values()} - for group in score_groups: - if len(group) <= 1: - continue - demand: Dict[str, List[str]] = {} - for pid in group: - choice = round.choices.get(pid) - if not choice: - continue - for sec_id in ( - choice.priority_sector_id, - choice.secondary_sector_id, - ): - if sec_id: - demand.setdefault(sec_id, []).append(pid) - for sector_id, demanders in demand.items(): - battle = sector_to_battle.get(sector_id) - if battle is None: - continue - places = len(battle.get_available_places()) - if len(demanders) <= places: - continue - context = TieContext( - ContextType.CHOICE, - round_id, - demanders, - score_value=None, - score_kind=ScoreKind.VP, - ) - if TieResolver.is_tie_resolved(war, context): - continue - if not TieResolver.can_tie_be_resolved( - war, - context, - demanders, - ): - war.events.append( - TieResolved( - None, - ContextType.CHOICE, - round_id, - ) - ) - continue - ties.append(context) - return ties - @staticmethod def find_battle_ties(war: War, round_id: str) -> List[TieContext]: round = war.get_round(round_id) @@ -104,12 +40,12 @@ class TieResolver: if TieResolver.is_tie_resolved(war, context): continue if campaign is None: - raise RuntimeError("No campaign for this battle tie") + raise DomainError("No campaign for this battle tie") if battle.player_1_id is None or battle.player_2_id is None: - raise RuntimeError("Missing player(s) in this battle context.") - p1 = campaign.participants[battle.player_1_id].war_participant_id - p2 = campaign.participants[battle.player_2_id].war_participant_id - if not TieResolver.can_tie_be_resolved(war, context, [p1, p2]): + raise DomainError("Missing player(s) in this battle context.") + p1_id = campaign.campaign_to_war_part_id(battle.player_1_id) + p2_id = campaign.campaign_to_war_part_id(battle.player_2_id) + if not TieResolver.can_tie_be_resolved(war, context, [p1_id, p2_id]): war.events.append( TieResolved(None, ContextType.BATTLE, battle.sector_id) ) @@ -118,7 +54,7 @@ class TieResolver: TieContext( context_type=ContextType.BATTLE, context_id=battle.sector_id, - participants=[p1, p2], + participants=[p1_id, p2_id], score_value=None, score_kind=None, ) @@ -240,8 +176,8 @@ class TieResolver: ContextType.WAR, war.id, [], - score_value, - ScoreKind.VP, + score_value=score_value, + score_kind=ScoreKind.VP, ) if TieResolver.is_tie_resolved(war, context): continue @@ -427,15 +363,15 @@ class TieResolver: context, context.participants, ) - # confirmed draw if non had bid + # confirmed draw if none had bid if not active: war.events.append( TieResolved( None, context.context_type, context.context_id, - context.score_value, - context.objective_id, + score_value=context.score_value, + objective_id=context.objective_id, ) ) return @@ -446,8 +382,8 @@ class TieResolver: None, context.context_type, context.context_id, - context.score_value, - context.objective_id, + score_value=context.score_value, + objective_id=context.objective_id, ) ) return diff --git a/src/warchron/model/war.py b/src/warchron/model/war.py index 0ba9476..62767e4 100644 --- a/src/warchron/model/war.py +++ b/src/warchron/model/war.py @@ -77,6 +77,7 @@ class War: new_events: List[WarEvent] = [] for ev in self.events: if isinstance(ev, (TieResolved)): + # FIXME cancel TieResolved + TokenSpent if ev.context_type == ContextType.BATTLE: battle = self.get_battle(ev.context_id) campaign = self.get_campaign_by_sector(battle.sector_id) diff --git a/src/warchron/model/war_event.py b/src/warchron/model/war_event.py index feb5842..a717241 100644 --- a/src/warchron/model/war_event.py +++ b/src/warchron/model/war_event.py @@ -77,10 +77,12 @@ class TieResolved(WarEvent): context_id: str, score_value: int | None = None, objective_id: str | None = None, + sector_id: str | None = None, ): super().__init__(participant_id, context_type, context_id) self.score_value = score_value self.objective_id = objective_id + self.sector_id = sector_id def toDict(self) -> Dict[str, Any]: d = super().toDict() @@ -88,6 +90,7 @@ class TieResolved(WarEvent): { "score_value": self.score_value or None, "objective_id": self.objective_id or None, + "sector_id": self.sector_id or None, } ) return d @@ -100,6 +103,7 @@ class TieResolved(WarEvent): data["context_id"], JsonHelper.none_if_empty(data["score_value"]), JsonHelper.none_if_empty(data["objective_id"]), + JsonHelper.none_if_empty(data["sector_id"]), ) return cls._base_fromDict(ev, data) diff --git a/test_data/example.json b/test_data/example.json index 58cb4dc..d40dfa2 100644 --- a/test_data/example.json +++ b/test_data/example.json @@ -340,7 +340,8 @@ "context_id": "79accf7c-2d93-4ac3-b747-e7092bfe3feb", "timestamp": "2026-02-26T16:11:44.346337", "score_value": null, - "objective_id": null + "objective_id": null, + "sector_id": null } ], "is_over": false