diff --git a/src/warchron/controller/campaign_controller.py b/src/warchron/controller/campaign_controller.py index 3ae38cb..4c72427 100644 --- a/src/warchron/controller/campaign_controller.py +++ b/src/warchron/controller/campaign_controller.py @@ -1,8 +1,8 @@ -from typing import List, TYPE_CHECKING +from typing import List, Dict, TYPE_CHECKING from PyQt6.QtWidgets import QMessageBox, QDialog -from warchron.constants import RefreshScope, ContextType +from warchron.constants import RefreshScope, ContextType, ItemType if TYPE_CHECKING: from warchron.controller.app_controller import AppController @@ -13,14 +13,18 @@ from warchron.controller.dtos import ( RoundDTO, CampaignParticipantScoreDTO, ) +from warchron.model.exception import ForbiddenOperation, DomainError +from warchron.model.war import War from warchron.model.campaign import Campaign from warchron.model.campaign_participant import CampaignParticipant from warchron.model.sector import Sector -from warchron.model.closure_service import ClosureService +from warchron.model.tie_manager import TieContext from warchron.model.score_service import ScoreService from warchron.view.campaign_dialog import CampaignDialog from warchron.view.campaign_participant_dialog import CampaignParticipantDialog from warchron.view.sector_dialog import SectorDialog +from warchron.controller.closure_workflow import CampaignClosureWorkflow +from warchron.view.tie_dialog import TieDialog class CampaignController: @@ -101,10 +105,6 @@ class CampaignController: return self.app.model.add_campaign( self.app.navigation.selected_war_id, name, month ) - # self.app.is_dirty = True - # self.app.navigation.refresh_and_select( - # RefreshScope.WARS_TREE, item_type=ItemType.CAMPAIGN, item_id=camp.id - # ) def edit_campaign(self, campaign_id: str) -> None: camp = self.app.model.get_campaign(campaign_id) @@ -123,25 +123,46 @@ class CampaignController: if not campaign_id: return camp = self.app.model.get_campaign(campaign_id) - if camp.is_over: - return + war = self.app.model.get_war_by_campaign(campaign_id) + workflow = CampaignClosureWorkflow(self.app) try: - ties = ClosureService.close_campaign(camp) - except RuntimeError as e: - QMessageBox.warning(self.app.view, "Cannot close campaign", str(e)) - return - if ties: - QMessageBox.information( + workflow.start(war, camp) + except DomainError as e: + QMessageBox.warning( self.app.view, - "Tie detected", - "Campaign has unresolved ties.", + "Deletion forbidden", + str(e), ) - return self.app.is_dirty = True self.app.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) - self.app.navigation.refresh(RefreshScope.WARS_TREE) + self.app.navigation.refresh_and_select( + RefreshScope.WARS_TREE, item_type=ItemType.CAMPAIGN, item_id=campaign_id + ) - # Campaign participant methods + def resolve_ties( + self, war: War, contexts: List[TieContext] + ) -> Dict[str, Dict[str, bool]]: + bids_map = {} + for ctx in contexts: + players = [ + ParticipantOption( + id=pid, + name=self.app.model.get_participant_name(pid), + ) + for pid in ctx.participants + ] + counters = [war.get_influence_tokens(pid) for pid in ctx.participants] + dialog = TieDialog( + parent=self.app.view, + players=players, + counters=counters, + context_type=ContextType.CAMPAIGN, + context_id=ctx.context_id, + ) + if not dialog.exec(): + raise ForbiddenOperation("Tie resolution cancelled") + bids_map[ctx.context_id] = dialog.get_bids() + return bids_map def create_campaign_participant(self) -> CampaignParticipant | None: if not self.app.navigation.selected_campaign_id: diff --git a/src/warchron/controller/closure_workflow.py b/src/warchron/controller/closure_workflow.py index 71a1e3d..5699d67 100644 --- a/src/warchron/controller/closure_workflow.py +++ b/src/warchron/controller/closure_workflow.py @@ -3,16 +3,12 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: from warchron.controller.app_controller import AppController -from warchron.constants import ContextType -from warchron.model.exception import ForbiddenOperation from warchron.model.war_event import TieResolved from warchron.model.war import War from warchron.model.campaign import Campaign -from warchron.model.battle import Battle from warchron.model.round import Round from warchron.model.closure_service import ClosureService from warchron.model.tie_manager import TieResolver -from warchron.controller.dtos import TieContext class ClosureWorkflow: @@ -25,53 +21,77 @@ class RoundClosureWorkflow(ClosureWorkflow): def start(self, war: War, campaign: Campaign, round: Round) -> None: ClosureService.check_round_closable(round) - ties = TieResolver.find_round_ties(round, war) + ties = TieResolver.find_battle_ties(war, round.id) while ties: - contexts = [ - RoundClosureWorkflow.build_battle_context(campaign, b) for b in ties - ] resolvable = [] - for ctx in contexts: - if TieResolver.can_tie_be_resolved(war, ctx.participants): - resolvable.append(ctx) + for tie in ties: + if TieResolver.can_tie_be_resolved(war, tie.participants): + resolvable.append(tie) else: war.events.append( TieResolved( - participant_id=None, - context_type=ctx.context_type, - context_id=ctx.context_id, + participant_id=None, # draw confirmed + context_type=tie.context_type, + context_id=tie.context_id, ) ) if not resolvable: break - bids_map = self.app.rounds.resolve_ties(war, contexts) - for ctx in contexts: - bids = bids_map[ctx.context_id] + bids_map = self.app.rounds.resolve_ties(war, resolvable) + for tie in resolvable: + bids = bids_map[tie.context_id] TieResolver.apply_bids( war, - ctx.context_type, - ctx.context_id, + tie.context_type, + tie.context_id, bids, ) TieResolver.try_tie_break( war, - ctx.context_type, - ctx.context_id, - ctx.participants, + tie.context_type, + tie.context_id, + tie.participants, ) - ties = TieResolver.find_round_ties(round, war) + ties = TieResolver.find_battle_ties(war, round.id) for battle in round.battles.values(): ClosureService.apply_battle_outcomes(war, campaign, battle) ClosureService.finalize_round(round) - @staticmethod - def build_battle_context(campaign: Campaign, battle: Battle) -> TieContext: - if battle.player_1_id is None or battle.player_2_id is None: - raise ForbiddenOperation("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 - return TieContext( - context_type=ContextType.BATTLE, - context_id=battle.sector_id, - participants=[p1, p2], - ) + +class CampaignClosureWorkflow(ClosureWorkflow): + + def start(self, war: War, campaign: Campaign) -> None: + ClosureService.check_campaign_closable(campaign) + ties = TieResolver.find_campaign_ties(war, campaign.id) + while ties: + resolvable = [] + for tie in ties: + if TieResolver.can_tie_be_resolved(war, tie.participants): + resolvable.append(tie) + else: + war.events.append( + TieResolved( + participant_id=None, + context_type=tie.context_type, + context_id=tie.context_id, + ) + ) + if not resolvable: + break + bids_map = self.app.campaigns.resolve_ties(war, resolvable) + for tie in resolvable: + bids = bids_map[tie.context_id] + TieResolver.apply_bids( + war, + tie.context_type, + tie.context_id, + bids, + ) + TieResolver.try_tie_break( + war, + tie.context_type, + tie.context_id, + tie.participants, + ) + ties = TieResolver.find_campaign_ties(war, campaign.id) + ClosureService.finalize_campaign(campaign) diff --git a/src/warchron/controller/dtos.py b/src/warchron/controller/dtos.py index bbfc45b..7d52aaf 100644 --- a/src/warchron/controller/dtos.py +++ b/src/warchron/controller/dtos.py @@ -3,8 +3,6 @@ from dataclasses import dataclass from PyQt6.QtGui import QIcon -from warchron.constants import ContextType - @dataclass(frozen=True) class ParticipantOption: @@ -109,13 +107,6 @@ class BattleDTO: player2_tooltip: str | None = None -@dataclass -class TieContext: - context_type: ContextType - context_id: str - participants: List[str] # war_participant_ids - - @dataclass(frozen=True, slots=True) class ParticipantScoreDTO: participant_id: str diff --git a/src/warchron/controller/round_controller.py b/src/warchron/controller/round_controller.py index d0c8601..8fdc4c9 100644 --- a/src/warchron/controller/round_controller.py +++ b/src/warchron/controller/round_controller.py @@ -6,7 +6,8 @@ from PyQt6.QtGui import QIcon from warchron.constants import ItemType, RefreshScope, Icons, IconName, ContextType from warchron.model.exception import ForbiddenOperation, DomainError -from warchron.model.tie_manager import TieResolver +from warchron.model.tie_manager import TieResolver, TieContext +from warchron.model.result_checker import ResultChecker from warchron.model.round import Round from warchron.model.war import War @@ -18,7 +19,6 @@ from warchron.controller.dtos import ( SectorDTO, ChoiceDTO, BattleDTO, - TieContext, ) from warchron.controller.closure_workflow import RoundClosureWorkflow from warchron.view.choice_dialog import ChoiceDialog @@ -108,7 +108,7 @@ class RoundController: if TieResolver.was_tie_broken_by_tokens( war, ContextType.BATTLE, battle.sector_id ): - effective_winner = TieResolver.get_effective_winner_id( + effective_winner = ResultChecker.get_effective_winner_id( war, ContextType.BATTLE, battle.sector_id, None ) p1_war = None diff --git a/src/warchron/model/closure_service.py b/src/warchron/model/closure_service.py index 5e6446e..93ab166 100644 --- a/src/warchron/model/closure_service.py +++ b/src/warchron/model/closure_service.py @@ -3,7 +3,7 @@ from typing import List from warchron.constants import ContextType from warchron.model.exception import ForbiddenOperation -from warchron.model.tie_manager import TieResolver +from warchron.model.result_checker import ResultChecker from warchron.model.war_event import InfluenceGained from warchron.model.war import War from warchron.model.campaign import Campaign @@ -35,7 +35,7 @@ class ClosureService: base_winner = None if battle.winner_id is not None: base_winner = campaign.participants[battle.winner_id].war_participant_id - effective_winner = TieResolver.get_effective_winner_id( + effective_winner = ResultChecker.get_effective_winner_id( war, ContextType.BATTLE, battle.sector_id, @@ -60,28 +60,17 @@ class ClosureService: # Campaign methods @staticmethod - def close_campaign(campaign: Campaign) -> List[str]: + def check_campaign_closable(campaign: Campaign) -> None: + if campaign.is_over: + raise ForbiddenOperation("Campaign already closed") if not campaign.all_rounds_finished(): - raise RuntimeError("All rounds must be finished to close their campaign") - ties: List[str] = [] - # for round in campaign.rounds: - # # compute score - # # if participants have same score - # ties.append( - # ResolutionContext( - # context_type=ContextType.CAMPAIGN, - # context_id=campaign.id, - # participant_ids=[ - # # TODO ref to War.participants at some point - # campaign.participants[campaign_participant_id], - # campaign.participants[campaign_participant_id], - # ], - # ) - # ) - if ties: - return ties + raise ForbiddenOperation( + "All rounds must be closed to close their campaign" + ) + + @staticmethod + def finalize_campaign(campaign: Campaign) -> None: campaign.is_over = True - return [] # War methods diff --git a/src/warchron/model/result_checker.py b/src/warchron/model/result_checker.py new file mode 100644 index 0000000..de07440 --- /dev/null +++ b/src/warchron/model/result_checker.py @@ -0,0 +1,24 @@ +from warchron.constants import ContextType +from warchron.model.war import War +from warchron.model.war_event import TieResolved + + +class ResultChecker: + @staticmethod + def get_effective_winner_id( + war: War, + context_type: ContextType, + context_id: str, + base_winner_id: str | None, + ) -> str | None: + if base_winner_id is not None: + return base_winner_id + for ev in reversed(war.events): + if ( + isinstance(ev, TieResolved) + and ev.context_type == context_type + and ev.context_id == context_id + ): + return ev.participant_id # None if confirmed draw + + return None diff --git a/src/warchron/model/score_service.py b/src/warchron/model/score_service.py index 9274f40..16da0ab 100644 --- a/src/warchron/model/score_service.py +++ b/src/warchron/model/score_service.py @@ -1,8 +1,8 @@ from typing import Dict, Iterator from dataclasses import dataclass, field +from warchron.model.result_checker import ResultChecker from warchron.constants import ContextType -from warchron.model.tie_manager import TieResolver from warchron.model.war import War from warchron.model.battle import Battle @@ -59,7 +59,7 @@ class ScoreService: base_winner = campaign.participants[ battle.winner_id ].war_participant_id - winner = TieResolver.get_effective_winner_id( + winner = ResultChecker.get_effective_winner_id( war, ContextType.BATTLE, battle.sector_id, base_winner ) if winner is None: diff --git a/src/warchron/model/tie_manager.py b/src/warchron/model/tie_manager.py index 54cef74..36c08ab 100644 --- a/src/warchron/model/tie_manager.py +++ b/src/warchron/model/tie_manager.py @@ -1,17 +1,27 @@ -from typing import List, Dict +from typing import List, Dict, DefaultDict +from dataclasses import dataclass +from collections import defaultdict from warchron.constants import ContextType from warchron.model.exception import ForbiddenOperation from warchron.model.war import War -from warchron.model.round import Round -from warchron.model.battle import Battle from warchron.model.war_event import InfluenceSpent, TieResolved +from warchron.model.score_service import ScoreService + + +@dataclass +class TieContext: + context_type: ContextType + context_id: str + participants: List[str] # war_participant_ids class TieResolver: @staticmethod - def find_round_ties(round: Round, war: War) -> List[Battle]: + def find_battle_ties(war: War, round_id: str) -> List[TieContext]: + round = war.get_round(round_id) + campaign = war.get_campaign_by_round(round_id) ties = [] for battle in round.battles.values(): if not battle.is_draw(): @@ -22,10 +32,53 @@ class TieResolver: and e.context_id == battle.sector_id for e in war.events ) - if not resolved: - ties.append(battle) + if resolved: + continue + if campaign is None: + raise RuntimeError("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 + ties.append( + TieContext( + context_type=ContextType.BATTLE, + context_id=battle.sector_id, + participants=[p1, p2], + ) + ) return ties + @staticmethod + def find_campaign_ties(war: War, campaign_id: str) -> List[TieContext]: + resolved = any( + isinstance(e, TieResolved) + and e.context_type == ContextType.CAMPAIGN + and e.context_id == campaign_id + for e in war.events + ) + if resolved: + return [] + scores = ScoreService.compute_scores(war, ContextType.CAMPAIGN, campaign_id) + buckets: DefaultDict[int, List[str]] = defaultdict(list) + for pid, score in scores.items(): + buckets[score.victory_points].append(pid) + ties = [] + for participants in buckets.values(): + if len(participants) > 1: + ties.append( + TieContext( + context_type=ContextType.CAMPAIGN, + context_id=campaign_id, + participants=participants, + ) + ) + return ties + + @staticmethod + def find_war_ties(war: War) -> List[TieContext]: + return [] # TODO + @staticmethod def apply_bids( war: War, @@ -88,25 +141,6 @@ class TieResolver: def can_tie_be_resolved(war: War, participants: List[str]) -> bool: return any(war.get_influence_tokens(pid) > 0 for pid in participants) - @staticmethod - def get_effective_winner_id( - war: War, - context_type: ContextType, - context_id: str, - base_winner_id: str | None, - ) -> str | None: - if base_winner_id is not None: - return base_winner_id - for ev in reversed(war.events): - if ( - isinstance(ev, TieResolved) - and ev.context_type == context_type - and ev.context_id == context_id - ): - return ev.participant_id # None if confirmed draw - - return None - @staticmethod def was_tie_broken_by_tokens( war: War, diff --git a/src/warchron/model/war.py b/src/warchron/model/war.py index 9091e61..8681d12 100644 --- a/src/warchron/model/war.py +++ b/src/warchron/model/war.py @@ -371,6 +371,14 @@ class War: # Round methods + # TODO replace multiloops by internal has_* method + def get_round(self, round_id: str) -> Round: + for camp in self.campaigns: + for rnd in camp.rounds: + if rnd.id == round_id: + return rnd + raise KeyError("Round not found") + def add_round(self, campaign_id: str) -> Round: camp = self.get_campaign(campaign_id) return camp.add_round() @@ -420,7 +428,7 @@ class War: for bat in rnd.battles.values(): if bat.sector_id == battle_id: return bat - raise KeyError("Round not found") + raise KeyError("Battle not found") def create_battle(self, round_id: str, sector_id: str) -> Battle: camp = self.get_campaign_by_round(round_id)