diff --git a/src/warchron/controller/app_controller.py b/src/warchron/controller/app_controller.py index 7f0bf9b..394d04b 100644 --- a/src/warchron/controller/app_controller.py +++ b/src/warchron/controller/app_controller.py @@ -96,6 +96,7 @@ class AppController: self.navigation.refresh_players_view() self.navigation.refresh_wars_view() self.update_window_title() + # TODO refresh details view if wars tab selected def open_file(self) -> None: if self.is_dirty: @@ -116,6 +117,7 @@ class AppController: self.navigation.refresh_players_view() self.navigation.refresh_wars_view() self.update_window_title() + # TODO refresh details view if wars tab selected def save(self) -> None: if not self.current_file: @@ -213,13 +215,13 @@ class AppController: self.navigation.refresh_and_select( RefreshScope.WARS_TREE, item_type=ItemType.ROUND, item_id=rnd.id ) + self.is_dirty = True except DomainError as e: QMessageBox.warning( self.view, - "Add forbidden", + "Deletion forbidden", str(e), ) - return except RequiresConfirmation as e: reply = QMessageBox.question( self.view, @@ -229,10 +231,7 @@ class AppController: ) if reply == QMessageBox.StandardButton.Yes: e.action() - else: - return - self.is_dirty = True - self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) + self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) def edit_item(self, item_type: str, item_id: str) -> None: try: @@ -267,13 +266,13 @@ class AppController: elif item_type == ItemType.BATTLE: self.rounds.edit_round_battle(item_id) self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) + self.is_dirty = True except DomainError as e: QMessageBox.warning( self.view, - "Update forbidden", + "Deletion forbidden", str(e), ) - return except RequiresConfirmation as e: reply = QMessageBox.question( self.view, @@ -283,10 +282,7 @@ class AppController: ) if reply == QMessageBox.StandardButton.Yes: e.action() - else: - return - self.is_dirty = True - self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) + self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) def delete_item(self, item_type: str, item_id: str) -> None: reply = QMessageBox.question( @@ -330,13 +326,13 @@ class AppController: self.navigation.refresh_and_select( RefreshScope.WARS_TREE, item_type=ItemType.CAMPAIGN, item_id=camp_id ) + self.is_dirty = True except DomainError as e: QMessageBox.warning( self.view, "Deletion forbidden", str(e), ) - return except RequiresConfirmation as e: reply = QMessageBox.question( self.view, @@ -346,7 +342,4 @@ class AppController: ) if reply == QMessageBox.StandardButton.Yes: e.action() - else: - return - self.is_dirty = True - self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) + self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) diff --git a/src/warchron/controller/campaign_controller.py b/src/warchron/controller/campaign_controller.py index 88199eb..d66154e 100644 --- a/src/warchron/controller/campaign_controller.py +++ b/src/warchron/controller/campaign_controller.py @@ -1,11 +1,15 @@ -from typing import List, Dict, Tuple, TYPE_CHECKING +from typing import List, Dict, TYPE_CHECKING from PyQt6.QtWidgets import QMessageBox, QDialog +from PyQt6.QtGui import QIcon from warchron.constants import ( RefreshScope, ContextType, ItemType, + Icons, + IconName, + RANK_TO_ICON, ) if TYPE_CHECKING: @@ -24,8 +28,8 @@ from warchron.model.campaign_participant import CampaignParticipant from warchron.model.sector import Sector from warchron.model.tie_manager import TieContext, TieResolver from warchron.model.score_service import ScoreService +from warchron.model.result_checker import ResultChecker from warchron.controller.closure_workflow import CampaignClosureWorkflow -from warchron.controller.ranking_icon import RankingIcon from warchron.view.campaign_dialog import CampaignDialog from warchron.view.campaign_participant_dialog import CampaignParticipantDialog from warchron.view.sector_dialog import SectorDialog @@ -36,6 +40,39 @@ class CampaignController: def __init__(self, app: "AppController"): self.app = app + def _compute_campaign_ranking_icons( + self, war: War, campaign: Campaign + ) -> Dict[str, QIcon]: + scores = ScoreService.compute_scores( + war, + ContextType.CAMPAIGN, + campaign.id, + ) + ranking = ResultChecker.get_effective_ranking( + war, ContextType.CAMPAIGN, campaign.id, scores + ) + icon_map = {} + for rank, group, token_map in ranking: + base_icon = RANK_TO_ICON.get(rank, IconName.VPNTH) + tie_id = f"{campaign.id}:score:{scores[group[0]].victory_points}" + tie_resolved = TieResolver.is_tie_resolved( + war, ContextType.CAMPAIGN, tie_id + ) + for pid in group: + spent = token_map.get(pid, 0) + if not tie_resolved and spent == 0: + icon_name = getattr(IconName, f"{base_icon.name}DRAW") + elif tie_resolved and spent == 0 and len(group) > 1: + icon_name = getattr(IconName, f"{base_icon.name}DRAW") + elif tie_resolved and spent > 0 and len(group) == 1: + icon_name = getattr(IconName, f"{base_icon.name}BREAK") + elif tie_resolved and spent > 0 and len(group) > 1: + icon_name = getattr(IconName, f"{base_icon.name}TIEDRAW") + else: + icon_name = base_icon + icon_map[pid] = QIcon(Icons.get_pixmap(icon_name)) + return icon_map + def _fill_campaign_details(self, campaign_id: str) -> None: camp = self.app.model.get_campaign(campaign_id) self.app.view.show_campaign_details(name=camp.name, month=camp.month) @@ -59,9 +96,7 @@ class CampaignController: rows: List[CampaignParticipantScoreDTO] = [] icon_map = {} if camp.is_over: - icon_map = RankingIcon.compute_icons( - war, ContextType.CAMPAIGN, campaign_id, scores - ) + icon_map = self._compute_campaign_ranking_icons(war, camp) for camp_part in camp.get_all_campaign_participants(): war_part_id = camp_part.war_participant_id war_part = war.get_war_participant(war_part_id) @@ -142,10 +177,9 @@ class CampaignController: except DomainError as e: QMessageBox.warning( self.app.view, - "Closure forbidden", + "Deletion forbidden", str(e), ) - return self.app.is_dirty = True self.app.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) self.app.navigation.refresh_and_select( @@ -154,7 +188,7 @@ class CampaignController: def resolve_ties( self, war: War, contexts: List[TieContext] - ) -> Dict[Tuple[ContextType, str, int | None], Dict[str, bool]]: + ) -> Dict[str, Dict[str, bool]]: bids_map = {} for ctx in contexts: active = TieResolver.get_active_participants( @@ -173,17 +207,11 @@ class CampaignController: context_id=ctx.context_id, ) if not dialog.exec(): - TieResolver.cancel_tie_break( - war, ContextType.CAMPAIGN, ctx.context_id, ctx.score_value - ) + TieResolver.cancel_tie_break(war, ContextType.CAMPAIGN, ctx.context_id) raise ForbiddenOperation("Tie resolution cancelled") - bids_map[(ctx.context_type, ctx.context_id, ctx.score_value)] = ( - dialog.get_bids() - ) + bids_map[ctx.context_id] = dialog.get_bids() return bids_map - # Campaign participant methods - def create_campaign_participant(self) -> CampaignParticipant | None: if not self.app.navigation.selected_campaign_id: return None @@ -242,6 +270,7 @@ class CampaignController: self.app.view, "Invalid name", "Sector name cannot be empty." ) return False + # TODO allow same objectives in different fields? return True def create_sector(self) -> Sector | None: diff --git a/src/warchron/controller/closure_workflow.py b/src/warchron/controller/closure_workflow.py index fe368ac..d668338 100644 --- a/src/warchron/controller/closure_workflow.py +++ b/src/warchron/controller/closure_workflow.py @@ -24,9 +24,11 @@ class RoundClosureWorkflow(ClosureWorkflow): while ties: bids_map = self.app.rounds.resolve_ties(war, ties) for tie in ties: - bids = bids_map[(tie.context_type, tie.context_id, tie.score_value)] + bids = bids_map[tie.context_id] TieResolver.apply_bids(war, tie.context_type, tie.context_id, bids) - TieResolver.resolve_tie_state(war, tie, bids) + TieResolver.resolve_tie_state( + war, tie.context_type, tie.context_id, tie.participants, bids + ) ties = TieResolver.find_battle_ties(war, round.id) for battle in round.battles.values(): ClosureService.apply_battle_outcomes(war, campaign, battle) @@ -41,9 +43,11 @@ class CampaignClosureWorkflow(ClosureWorkflow): while ties: bids_map = self.app.campaigns.resolve_ties(war, ties) for tie in ties: - bids = bids_map[(tie.context_type, tie.context_id, tie.score_value)] + bids = bids_map[tie.context_id] TieResolver.apply_bids(war, tie.context_type, tie.context_id, bids) - TieResolver.resolve_tie_state(war, tie, bids) + TieResolver.resolve_tie_state( + war, tie.context_type, tie.context_id, tie.participants, bids + ) ties = TieResolver.find_campaign_ties(war, campaign.id) ClosureService.finalize_campaign(campaign) @@ -56,8 +60,10 @@ class WarClosureWorkflow(ClosureWorkflow): while ties: bids_map = self.app.wars.resolve_ties(war, ties) for tie in ties: - bids = bids_map[(tie.context_type, tie.context_id, tie.score_value)] + bids = bids_map[tie.context_id] TieResolver.apply_bids(war, tie.context_type, tie.context_id, bids) - TieResolver.resolve_tie_state(war, tie, bids) + TieResolver.resolve_tie_state( + war, tie.context_type, tie.context_id, tie.participants, bids + ) ties = TieResolver.find_war_ties(war) ClosureService.finalize_war(war) diff --git a/src/warchron/controller/navigation_controller.py b/src/warchron/controller/navigation_controller.py index 2a6aa47..fe84943 100644 --- a/src/warchron/controller/navigation_controller.py +++ b/src/warchron/controller/navigation_controller.py @@ -31,8 +31,7 @@ class NavigationController: self.app.view.display_players(players_for_display) def refresh_wars_view(self) -> None: - wars = self.app.model.get_all_wars() - wars_dto: List[WarDTO] = [ + wars: List[WarDTO] = [ WarDTO( id=w.id, name=w.name, @@ -58,21 +57,7 @@ class NavigationController: ) for w in self.app.model.get_all_wars() ] - self.app.view.display_wars_tree(wars_dto) - if not wars: - self.clear_selection() - return - first_war = wars[0] - self.selected_war_id = first_war.id - self.selected_campaign_id = None - self.selected_round_id = None - self.app.view.select_tree_item( - item_type=ItemType.WAR, - item_id=first_war.id, - ) - self.app.view.show_details(ItemType.WAR) - self.app.wars._fill_war_details(first_war.id) - self.update_actions_state() + self.app.view.display_wars_tree(wars) def refresh(self, scope: RefreshScope) -> None: match scope: @@ -102,13 +87,6 @@ class NavigationController: # Commands methods - def clear_selection(self) -> None: - self.selected_war_id = None - self.selected_campaign_id = None - self.selected_round_id = None - self.app.view.clear_tree_selection() - self.app.view.show_details(None) - def on_tree_selection_changed(self, selection: TreeSelection | None) -> None: self.selected_war_id = None self.selected_campaign_id = None diff --git a/src/warchron/controller/ranking_icon.py b/src/warchron/controller/ranking_icon.py deleted file mode 100644 index bb7d914..0000000 --- a/src/warchron/controller/ranking_icon.py +++ /dev/null @@ -1,54 +0,0 @@ -from typing import Dict - -from PyQt6.QtGui import QIcon - -from warchron.constants import ( - ContextType, - Icons, - IconName, - RANK_TO_ICON, -) -from warchron.model.war import War -from warchron.model.score_service import ParticipantScore -from warchron.model.result_checker import ResultChecker - - -class RankingIcon: - @staticmethod - def compute_icons( - war: War, - context_type: ContextType, - context_id: str, - scores: Dict[str, ParticipantScore], - ) -> Dict[str, QIcon]: - # scores = ScoreService.compute_scores( - # war, - # context_type, - # context_id, - # ) - ranking = ResultChecker.get_effective_ranking( - war, context_type, context_id, scores - ) - icon_map = {} - for rank, group, token_map in ranking: - base_icon = RANK_TO_ICON.get(rank, IconName.VPNTH) - vp = scores[group[0]].victory_points - original_group_size = sum( - 1 for s in scores.values() if s.victory_points == vp - ) - for pid in group: - spent = token_map.get(pid, 0) - if original_group_size == 1: - icon_name = base_icon - elif len(group) == 1: - if spent > 0: - icon_name = getattr(IconName, f"{base_icon.name}BREAK") - else: - icon_name = base_icon - else: - if spent > 0: - icon_name = getattr(IconName, f"{base_icon.name}TIEDRAW") - else: - icon_name = getattr(IconName, f"{base_icon.name}DRAW") - icon_map[pid] = QIcon(Icons.get_pixmap(icon_name)) - return icon_map diff --git a/src/warchron/controller/round_controller.py b/src/warchron/controller/round_controller.py index 5af114b..8fdc4c9 100644 --- a/src/warchron/controller/round_controller.py +++ b/src/warchron/controller/round_controller.py @@ -1,4 +1,4 @@ -from typing import List, Dict, Tuple, TYPE_CHECKING +from typing import List, Dict, TYPE_CHECKING from PyQt6.QtWidgets import QDialog from PyQt6.QtWidgets import QMessageBox @@ -167,10 +167,9 @@ class RoundController: except DomainError as e: QMessageBox.warning( self.app.view, - "Closure forbidden", + "Deletion forbidden", str(e), ) - return self.app.is_dirty = True self.app.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) self.app.navigation.refresh_and_select( @@ -179,7 +178,7 @@ class RoundController: def resolve_ties( self, war: War, contexts: List[TieContext] - ) -> Dict[Tuple[ContextType, str, int | None], Dict[str, bool]]: + ) -> Dict[str, Dict[str, bool]]: bids_map = {} for ctx in contexts: players = [ @@ -198,13 +197,8 @@ class RoundController: context_id=ctx.context_id, ) if not dialog.exec(): - TieResolver.cancel_tie_break( - war, ContextType.BATTLE, ctx.context_id, ctx.score_value - ) raise ForbiddenOperation("Tie resolution cancelled") - bids_map[(ctx.context_type, ctx.context_id, ctx.score_value)] = ( - dialog.get_bids() - ) + bids_map[ctx.context_id] = dialog.get_bids() return bids_map # Choice methods diff --git a/src/warchron/controller/war_controller.py b/src/warchron/controller/war_controller.py index 84977e9..54f28f6 100644 --- a/src/warchron/controller/war_controller.py +++ b/src/warchron/controller/war_controller.py @@ -1,17 +1,17 @@ -from typing import List, Tuple, TYPE_CHECKING, Dict +from typing import List, TYPE_CHECKING, Dict from PyQt6.QtWidgets import QMessageBox, QDialog +from PyQt6.QtGui import QIcon from warchron.constants import ( RefreshScope, ItemType, ContextType, + Icons, + IconName, + RANK_TO_ICON, ) -from warchron.model.exception import ( - DomainError, - ForbiddenOperation, - RequiresConfirmation, -) +from warchron.model.exception import DomainError, ForbiddenOperation if TYPE_CHECKING: from warchron.controller.app_controller import AppController @@ -25,8 +25,8 @@ from warchron.model.war_participant import WarParticipant from warchron.model.objective import Objective from warchron.model.tie_manager import TieContext, TieResolver from warchron.model.score_service import ScoreService +from warchron.model.result_checker import ResultChecker from warchron.controller.closure_workflow import WarClosureWorkflow -from warchron.controller.ranking_icon import RankingIcon from warchron.view.war_dialog import WarDialog from warchron.view.objective_dialog import ObjectiveDialog from warchron.view.war_participant_dialog import WarParticipantDialog @@ -37,6 +37,35 @@ class WarController: def __init__(self, app: "AppController"): self.app = app + def _compute_war_ranking_icons(self, war: War) -> Dict[str, QIcon]: + scores = ScoreService.compute_scores( + war, + ContextType.WAR, + war.id, + ) + ranking = ResultChecker.get_effective_ranking( + war, ContextType.WAR, war.id, scores + ) + icon_map = {} + for rank, group, token_map in ranking: + base_icon = RANK_TO_ICON.get(rank, IconName.VPNTH) + tie_id = f"{war.id}:score:{scores[group[0]].victory_points}" + tie_resolved = TieResolver.is_tie_resolved(war, ContextType.WAR, tie_id) + for pid in group: + spent = token_map.get(pid, 0) + if not tie_resolved and spent == 0: + icon_name = getattr(IconName, f"{base_icon.name}DRAW") + elif tie_resolved and spent == 0 and len(group) > 1: + icon_name = getattr(IconName, f"{base_icon.name}DRAW") + elif tie_resolved and spent > 0 and len(group) == 1: + icon_name = getattr(IconName, f"{base_icon.name}BREAK") + elif tie_resolved and spent > 0 and len(group) > 1: + icon_name = getattr(IconName, f"{base_icon.name}TIEDRAW") + else: + icon_name = base_icon + icon_map[pid] = QIcon(Icons.get_pixmap(icon_name)) + return icon_map + def _fill_war_details(self, war_id: str) -> None: war = self.app.model.get_war(war_id) self.app.view.show_war_details(name=war.name, year=war.year) @@ -55,7 +84,7 @@ class WarController: rows: List[WarParticipantScoreDTO] = [] icon_map = {} if war.is_over: - icon_map = RankingIcon.compute_icons(war, ContextType.WAR, war_id, scores) + icon_map = self._compute_war_ranking_icons(war) for war_part in war.get_all_war_participants(): player_name = self.app.model.get_player_name(war_part.player_id) score = scores[war_part.id] @@ -123,26 +152,23 @@ class WarController: except DomainError as e: QMessageBox.warning( self.app.view, - "Closure forbidden", + "Deletion forbidden", str(e), ) - return self.app.is_dirty = True self.app.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) self.app.navigation.refresh_and_select( RefreshScope.WARS_TREE, item_type=ItemType.WAR, item_id=war_id ) + # TODO fix ignored campaign tie-breaks def resolve_ties( self, war: War, contexts: List[TieContext] - ) -> Dict[Tuple[ContextType, str, int | None], Dict[str, bool]]: + ) -> Dict[str, Dict[str, bool]]: bids_map = {} for ctx in contexts: active = TieResolver.get_active_participants( - war, - ctx.context_type, - ctx.context_id, - ctx.participants, + war, ctx.context_type, ctx.context_id, ctx.participants ) players = [ ParticipantOption(id=pid, name=self.app.model.get_participant_name(pid)) @@ -157,13 +183,9 @@ class WarController: context_id=ctx.context_id, ) if not dialog.exec(): - TieResolver.cancel_tie_break( - war, ContextType.WAR, ctx.context_id, ctx.score_value - ) + TieResolver.cancel_tie_break(war, ContextType.WAR, ctx.context_id) raise ForbiddenOperation("Tie resolution cancelled") - bids_map[(ctx.context_type, ctx.context_id, ctx.score_value)] = ( - dialog.get_bids() - ) + bids_map[ctx.context_id] = dialog.get_bids() return bids_map def set_major_value(self, value: int) -> None: @@ -178,7 +200,6 @@ class WarController: "Setting forbidden", str(e), ) - return self.app.is_dirty = True self.app.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) @@ -194,7 +215,6 @@ class WarController: "Setting forbidden", str(e), ) - return self.app.is_dirty = True self.app.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) @@ -210,23 +230,8 @@ class WarController: "Setting forbidden", str(e), ) - return - except RequiresConfirmation as e: - reply = QMessageBox.question( - self.app.view, - "Confirm update", - str(e), - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - ) - if reply == QMessageBox.StandardButton.Yes: - e.action() - else: - return self.app.is_dirty = True self.app.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) - self.app.navigation.refresh_and_select( - RefreshScope.WARS_TREE, item_type=ItemType.WAR, item_id=war_id - ) # Objective methods diff --git a/src/warchron/model/closure_service.py b/src/warchron/model/closure_service.py index 0cc16f3..21a3b5f 100644 --- a/src/warchron/model/closure_service.py +++ b/src/warchron/model/closure_service.py @@ -27,7 +27,8 @@ class ClosureService: from warchron.model.result_checker import ResultChecker already_granted = any( - isinstance(e, InfluenceGained) and e.context_id == battle.sector_id + isinstance(e, InfluenceGained) + and e.context_id == f"battle:{battle.sector_id}" for e in war.events ) if already_granted: @@ -50,7 +51,7 @@ class ClosureService: participant_id=effective_winner, amount=1, context_type=ContextType.BATTLE, - context_id=battle.sector_id, + context_id=f"battle:{battle.sector_id}", ) ) diff --git a/src/warchron/model/result_checker.py b/src/warchron/model/result_checker.py index 6430ef6..d69003a 100644 --- a/src/warchron/model/result_checker.py +++ b/src/warchron/model/result_checker.py @@ -45,42 +45,10 @@ class ResultChecker: current_rank = 1 for vp in sorted_vps: participants = vp_buckets[vp] - if context_type == ContextType.WAR and len(participants) > 1: - subgroups = ResultChecker._secondary_sorting_war(war, participants) - for subgroup in subgroups: - # no tie if campaigns' rank is enough to sort - if len(subgroup) == 1: - ranking.append( - (current_rank, subgroup, {pid: 0 for pid in subgroup}) - ) - current_rank += 1 - continue - # normal tie-break if tie persists - if not TieResolver.is_tie_resolved( - war, context_type, context_id, vp - ): - ranking.append( - (current_rank, subgroup, {pid: 0 for pid in subgroup}) - ) - current_rank += 1 - continue - groups = TieResolver.rank_by_tokens( - war, - context_type, - context_id, - subgroup, - ) - tokens_spent = TieResolver.tokens_spent_map( - war, context_type, context_id, subgroup - ) - for group in groups: - group_tokens = {pid: tokens_spent[pid] for pid in group} - ranking.append((current_rank, group, group_tokens)) - current_rank += 1 - continue + tie_id = f"{context_id}:score:{vp}" # no tie if len(participants) == 1 or not TieResolver.is_tie_resolved( - war, context_type, context_id, vp + war, context_type, tie_id ): ranking.append( (current_rank, participants, {pid: 0 for pid in participants}) @@ -91,46 +59,14 @@ class ResultChecker: groups = TieResolver.rank_by_tokens( war, context_type, - context_id, + tie_id, participants, ) tokens_spent = TieResolver.tokens_spent_map( - war, context_type, context_id, participants + war, context_type, tie_id, participants ) for group in groups: group_tokens = {pid: tokens_spent[pid] for pid in group} ranking.append((current_rank, group, group_tokens)) current_rank += 1 return ranking - - @staticmethod - def _secondary_sorting_war( - war: War, - participants: List[str], - ) -> List[List[str]]: - from warchron.model.score_service import ScoreService - - rank_map: Dict[str, Tuple[int, ...]] = {} - for pid in participants: - ranks: List[int] = [] - for campaign in war.campaigns: - scores = ScoreService.compute_scores( - war, ContextType.CAMPAIGN, campaign.id - ) - ranking = ResultChecker.get_effective_ranking( - war, ContextType.CAMPAIGN, campaign.id, scores - ) - for rank, group, _ in ranking: - if pid in group: - ranks.append(rank) - break - rank_map[pid] = tuple(ranks) - sorted_items = sorted(rank_map.items(), key=lambda x: x[1]) - groups: List[List[str]] = [] - current_tuple = None - for pid, rank_tuple in sorted_items: - if rank_tuple != current_tuple: - groups.append([]) - current_tuple = rank_tuple - groups[-1].append(pid) - return groups diff --git a/src/warchron/model/tie_manager.py b/src/warchron/model/tie_manager.py index e174e8e..c398592 100644 --- a/src/warchron/model/tie_manager.py +++ b/src/warchron/model/tie_manager.py @@ -14,7 +14,6 @@ class TieContext: context_type: ContextType context_id: str participants: List[str] # war_participant_ids - score_value: int | None = None class TieResolver: @@ -27,10 +26,9 @@ class TieResolver: for battle in round.battles.values(): if not battle.is_draw(): continue - if TieResolver.is_tie_resolved( - war, ContextType.BATTLE, battle.sector_id, None - ): + if TieResolver.is_tie_resolved(war, ContextType.BATTLE, battle.sector_id): 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: @@ -49,13 +47,14 @@ class TieResolver: context_type=ContextType.BATTLE, context_id=battle.sector_id, participants=[p1, p2], - score_value=None, ) ) return ties @staticmethod def find_campaign_ties(war: War, campaign_id: str) -> List[TieContext]: + if TieResolver.is_tie_resolved(war, ContextType.CAMPAIGN, campaign_id): + return [] scores = ScoreService.compute_scores(war, ContextType.CAMPAIGN, campaign_id) buckets: DefaultDict[int, List[str]] = defaultdict(list) for pid, score in scores.items(): @@ -64,53 +63,48 @@ class TieResolver: for score_value, participants in buckets.items(): if len(participants) <= 1: continue - if TieResolver.is_tie_resolved( - war, ContextType.CAMPAIGN, campaign_id, score_value - ): + tie_id = f"{campaign_id}:score:{score_value}" + if TieResolver.is_tie_resolved(war, ContextType.CAMPAIGN, tie_id): continue if not TieResolver.can_tie_be_resolved( - war, ContextType.CAMPAIGN, campaign_id, participants + war, ContextType.CAMPAIGN, tie_id, participants ): - war.events.append( - TieResolved(None, ContextType.CAMPAIGN, campaign_id, score_value) - ) + war.events.append(TieResolved(None, ContextType.CAMPAIGN, tie_id)) continue ties.append( TieContext( context_type=ContextType.CAMPAIGN, - context_id=campaign_id, + context_id=tie_id, participants=participants, - score_value=score_value, ) ) return ties @staticmethod def find_war_ties(war: War) -> List[TieContext]: - from warchron.model.result_checker import ResultChecker - + if TieResolver.is_tie_resolved(war, ContextType.WAR, war.id): + return [] scores = ScoreService.compute_scores(war, ContextType.WAR, war.id) - ranking = ResultChecker.get_effective_ranking( - war, ContextType.WAR, war.id, scores - ) + buckets: DefaultDict[int, List[str]] = defaultdict(list) + for pid, score in scores.items(): + buckets[score.victory_points].append(pid) ties: List[TieContext] = [] - for _, group, _ in ranking: - if len(group) <= 1: + for score_value, participants in buckets.items(): + if len(participants) <= 1: continue - score_value = scores[group[0]].victory_points - if TieResolver.is_tie_resolved(war, ContextType.WAR, war.id, score_value): + tie_id = f"{war.id}:score:{score_value}" + if TieResolver.is_tie_resolved(war, ContextType.WAR, tie_id): continue - if not TieResolver.can_tie_be_resolved(war, ContextType.WAR, war.id, group): - war.events.append( - TieResolved(None, ContextType.WAR, war.id, score_value) - ) + if not TieResolver.can_tie_be_resolved( + war, ContextType.WAR, tie_id, participants + ): + war.events.append(TieResolved(None, ContextType.WAR, tie_id)) continue ties.append( TieContext( context_type=ContextType.WAR, - context_id=war.id, - participants=group, - score_value=score_value, + context_id=tie_id, + participants=participants, ) ) return ties @@ -141,7 +135,6 @@ class TieResolver: war: War, context_type: ContextType, context_id: str, - score_value: int | None = None, ) -> None: war.events = [ ev @@ -156,7 +149,6 @@ class TieResolver: isinstance(ev, TieResolved) and ev.context_type == context_type and ev.context_id == context_id - and ev.score_value == score_value ) ) ] @@ -218,37 +210,26 @@ class TieResolver: @staticmethod def resolve_tie_state( war: War, - ctx: TieContext, + context_type: ContextType, + context_id: str, + participants: List[str], bids: dict[str, bool] | None = None, ) -> None: active = TieResolver.get_active_participants( - war, - ctx.context_type, - ctx.context_id, - ctx.participants, + war, context_type, context_id, participants ) # confirmed draw if non had bid if not active: - war.events.append( - TieResolved(None, ctx.context_type, ctx.context_id, ctx.score_value) - ) + war.events.append(TieResolved(None, context_type, context_id)) return # confirmed draw if current bids are 0 if bids is not None and not any(bids.values()): - war.events.append( - TieResolved(None, ctx.context_type, ctx.context_id, ctx.score_value) - ) + war.events.append(TieResolved(None, context_type, context_id)) return # else rank_by_tokens - groups = TieResolver.rank_by_tokens( - war, ctx.context_type, ctx.context_id, ctx.participants - ) + groups = TieResolver.rank_by_tokens(war, context_type, context_id, participants) if len(groups[0]) == 1: - war.events.append( - TieResolved( - groups[0][0], ctx.context_type, ctx.context_id, ctx.score_value - ) - ) + war.events.append(TieResolved(groups[0][0], context_type, context_id)) return # if tie persists, do nothing, workflow will call again @@ -266,29 +247,21 @@ class TieResolver: war: War, context_type: ContextType, context_id: str, - score_value: int | None = None, ) -> bool: for ev in reversed(war.events): if ( isinstance(ev, TieResolved) and ev.context_type == context_type and ev.context_id == context_id - and ev.score_value == score_value ): return ev.participant_id is not None return False @staticmethod - def is_tie_resolved( - war: War, - context_type: ContextType, - context_id: str, - score_value: int | None = None, - ) -> bool: + def is_tie_resolved(war: War, context_type: ContextType, context_id: str) -> bool: return any( isinstance(ev, TieResolved) and ev.context_type == context_type and ev.context_id == context_id - and ev.score_value == score_value for ev in war.events ) diff --git a/src/warchron/model/war.py b/src/warchron/model/war.py index 360d70e..8681d12 100644 --- a/src/warchron/model/war.py +++ b/src/warchron/model/war.py @@ -3,14 +3,8 @@ from uuid import uuid4 from datetime import datetime from typing import Any, Dict, List -from warchron.constants import ContextType -from warchron.model.war_event import ( - WarEvent, - InfluenceGained, - InfluenceSpent, - TieResolved, -) -from warchron.model.exception import ForbiddenOperation, RequiresConfirmation +from warchron.model.war_event import WarEvent, InfluenceGained, InfluenceSpent +from warchron.model.exception import ForbiddenOperation from warchron.model.war_participant import WarParticipant from warchron.model.objective import Objective from warchron.model.campaign_participant import CampaignParticipant @@ -61,66 +55,10 @@ class War: def set_influence_token(self, new_state: bool) -> None: if self.is_over: raise ForbiddenOperation("Can't set influence token of a closed war.") - - def cleanup_token_and_tie() -> None: - new_events: List[WarEvent] = [] - for ev in self.events: - if isinstance(ev, (InfluenceSpent, InfluenceGained)): - continue - if isinstance(ev, TieResolved): - ev.set_participant(None) - new_events.append(ev) - self.events = new_events - self.influence_token = new_state - - def reset_tie_break() -> None: - new_events: List[WarEvent] = [] - for ev in self.events: - if isinstance(ev, (TieResolved)): - if ev.context_type == ContextType.BATTLE: - battle = self.get_battle(ev.context_id) - campaign = self.get_campaign_by_sector(battle.sector_id) - round = campaign.get_round_by_battle(ev.context_id) - round.is_over = False - elif ev.context_type == ContextType.CAMPAIGN: - campaign = self.get_campaign(ev.context_id) - campaign.is_over = False - else: - new_events.append(ev) - self.events = new_events - for camp in self.campaigns: - for sect in camp.get_all_sectors(): - if sect.influence_objective_id is not None: - round = camp.get_round_by_battle(sect.id) - round.is_over = False - self.influence_token = new_state - - if new_state is False: - if not self.events: - self.influence_token = new_state - return - raise RequiresConfirmation( - "Some influence tokens already exist in this war.\n" - "All tokens will be deleted and tie-breaks will become draw.\n" - "Do you want to continue?", - action=cleanup_token_and_tie, - ) - if new_state is True: - has_tie_resolved = any(isinstance(ev, TieResolved) for ev in self.events) - grant_influence = any( - (sect.influence_objective_id is not None) - for camp in self.campaigns - for sect in camp.get_all_sectors() - ) - if not has_tie_resolved and not grant_influence: - self.influence_token = new_state - return - raise RequiresConfirmation( - "Some influence tokens and draws exist in this war.\n" - "All influence outcomes and tie-breaks will be reset.\n" - "Do you want to continue?", - action=reset_tie_break, - ) + # TODO raise RequiresConfirmation + # * disable: cleanup if any token has already been gained/spent + # * enable: retrigger battle_outcomes and resolve tie again if any draw + self.influence_token = new_state def set_state(self, new_state: bool) -> None: self.is_over = new_state diff --git a/src/warchron/model/war_event.py b/src/warchron/model/war_event.py index 3f3f4db..62209cd 100644 --- a/src/warchron/model/war_event.py +++ b/src/warchron/model/war_event.py @@ -63,32 +63,19 @@ class WarEvent: class TieResolved(WarEvent): TYPE = "TieResolved" - def __init__( - self, - participant_id: str | None, - context_type: str, - context_id: str, - score_value: int | None = None, - ): + def __init__(self, participant_id: str | None, context_type: str, context_id: str): super().__init__(participant_id, context_type, context_id) - self.score_value = score_value def toDict(self) -> Dict[str, Any]: d = super().toDict() - d.update( - { - "score_value": self.score_value or None, - } - ) return d @classmethod def fromDict(cls, data: Dict[str, Any]) -> TieResolved: ev = cls( - data["participant_id"] or None, + data["participant_id"], data["context_type"], data["context_id"], - data["score_value"] or None, ) return cls._base_fromDict(ev, data) diff --git a/src/warchron/view/view.py b/src/warchron/view/view.py index 31354a1..5e87ce3 100644 --- a/src/warchron/view/view.py +++ b/src/warchron/view/view.py @@ -90,7 +90,6 @@ class View(QtWidgets.QMainWindow, Ui_MainWindow): ) self.majorValue.valueChanged.connect(self._on_major_changed) self.minorValue.valueChanged.connect(self._on_minor_changed) - self.warsTree.currentItemChanged.connect(self._emit_selection_changed) self._apply_icons() def _apply_icons(self) -> None: @@ -144,11 +143,6 @@ class View(QtWidgets.QMainWindow, Ui_MainWindow): return "wars" return "" - def clear_tree_selection(self) -> None: - self.warsTree.blockSignals(True) - self.warsTree.setCurrentItem(None) - self.warsTree.blockSignals(False) - # General popups def closeEvent(self, event: QCloseEvent | None = None) -> None: @@ -243,7 +237,10 @@ class View(QtWidgets.QMainWindow, Ui_MainWindow): def display_wars_tree(self, wars: List[WarDTO]) -> None: tree = self.warsTree - tree.blockSignals(True) + try: + tree.currentItemChanged.disconnect() + except TypeError: + pass tree.clear() tree.setColumnCount(1) tree.setHeaderLabels(["Wars"]) @@ -267,8 +264,8 @@ class View(QtWidgets.QMainWindow, Ui_MainWindow): rnd_item.setData(0, ROLE_TYPE, ItemType.ROUND) rnd_item.setData(0, ROLE_ID, rnd.id) camp_item.addChild(rnd_item) + tree.currentItemChanged.connect(self._emit_selection_changed) tree.expandAll() - tree.blockSignals(False) def select_tree_item(self, *, item_type: ItemType, item_id: str) -> None: def walk(item: QTreeWidgetItem) -> bool: