diff --git a/src/warchron/controller/closure_workflow.py b/src/warchron/controller/closure_workflow.py index 01076d2..d668338 100644 --- a/src/warchron/controller/closure_workflow.py +++ b/src/warchron/controller/closure_workflow.py @@ -50,3 +50,20 @@ class CampaignClosureWorkflow(ClosureWorkflow): ) ties = TieResolver.find_campaign_ties(war, campaign.id) ClosureService.finalize_campaign(campaign) + + +class WarClosureWorkflow(ClosureWorkflow): + + def start(self, war: War) -> None: + ClosureService.check_war_closable(war) + ties = TieResolver.find_war_ties(war) + while ties: + bids_map = self.app.wars.resolve_ties(war, ties) + for tie in ties: + bids = bids_map[tie.context_id] + TieResolver.apply_bids(war, tie.context_type, tie.context_id, 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/dtos.py b/src/warchron/controller/dtos.py index 4668ff7..3632e2f 100644 --- a/src/warchron/controller/dtos.py +++ b/src/warchron/controller/dtos.py @@ -125,3 +125,14 @@ class CampaignParticipantScoreDTO: victory_points: int narrative_points: Dict[str, int] rank_icon: QIcon | None = None + + +@dataclass(frozen=True, slots=True) +class WarParticipantScoreDTO: + war_participant_id: str + player_id: str + player_name: str + faction: str + victory_points: int + narrative_points: Dict[str, int] + rank_icon: QIcon | None = None diff --git a/src/warchron/controller/war_controller.py b/src/warchron/controller/war_controller.py index 9851333..fcb5f0b 100644 --- a/src/warchron/controller/war_controller.py +++ b/src/warchron/controller/war_controller.py @@ -1,30 +1,71 @@ -from typing import List, TYPE_CHECKING +from typing import List, TYPE_CHECKING, Dict from PyQt6.QtWidgets import QMessageBox, QDialog +from PyQt6.QtGui import QIcon -from warchron.constants import RefreshScope -from warchron.model.exception import DomainError +from warchron.constants import ( + RefreshScope, + ItemType, + ContextType, + Icons, + IconName, + RANK_TO_ICON, +) +from warchron.model.exception import DomainError, ForbiddenOperation if TYPE_CHECKING: from warchron.controller.app_controller import AppController from warchron.controller.dtos import ( ParticipantOption, - WarParticipantDTO, + WarParticipantScoreDTO, ObjectiveDTO, ) from warchron.model.war import War from warchron.model.war_participant import WarParticipant from warchron.model.objective import Objective -from warchron.model.closure_service import ClosureService +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.view.war_dialog import WarDialog from warchron.view.objective_dialog import ObjectiveDialog from warchron.view.war_participant_dialog import WarParticipantDialog +from warchron.view.tie_dialog import TieDialog 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) @@ -39,17 +80,27 @@ class WarController: for obj in objectives ] self.app.view.display_war_objectives(objectives_for_display) - war_parts = war.get_all_war_participants() - participants_for_display: List[WarParticipantDTO] = [ - WarParticipantDTO( - id=p.id, - player_name=self.app.model.get_player_name(p.player_id), - faction=p.faction, + scores = ScoreService.compute_scores(war, ContextType.WAR, war.id) + rows: List[WarParticipantScoreDTO] = [] + icon_map = {} + if war.is_over: + 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] + rows.append( + WarParticipantScoreDTO( + war_participant_id=war_part.id, + player_id=war_part.player_id, + player_name=player_name, + faction=war_part.faction or "", + victory_points=score.victory_points, + narrative_points=dict(score.narrative_points), + rank_icon=icon_map.get(war_part.id), + ) ) - for p in war_parts - ] - self.app.view.display_war_participants(participants_for_display) - self.app.view.endWarBtn.setEnabled(not war.is_over) + self.app.view.display_war_participants(rows, objectives_for_display) + self.app.view.endCampaignBtn.setEnabled(not war.is_over) def _validate_war_inputs(self, name: str, year: int) -> bool: if not name.strip(): @@ -94,23 +145,45 @@ class WarController: if not war_id: return war = self.app.model.get_war(war_id) - if war.is_over: - return + workflow = WarClosureWorkflow(self.app) try: - ties = ClosureService.close_war(war) - except RuntimeError as e: - QMessageBox.warning(self.app.view, "Cannot close war", str(e)) - return - if ties: - QMessageBox.information( + workflow.start(war) + except DomainError as e: + QMessageBox.warning( self.app.view, - "Tie detected", - "War 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.WAR, item_id=war_id + ) + + def resolve_ties( + self, war: War, contexts: List[TieContext] + ) -> 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 + ) + players = [ + ParticipantOption(id=pid, name=self.app.model.get_participant_name(pid)) + for pid in active + ] + counters = [war.get_influence_tokens(pid) for pid in active] + dialog = TieDialog( + parent=self.app.view, + players=players, + counters=counters, + context_type=ContextType.WAR, + 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 set_major_value(self, value: int) -> None: war_id = self.app.navigation.selected_war_id diff --git a/src/warchron/model/closure_service.py b/src/warchron/model/closure_service.py index 6f62997..21a3b5f 100644 --- a/src/warchron/model/closure_service.py +++ b/src/warchron/model/closure_service.py @@ -1,5 +1,4 @@ from __future__ import annotations -from typing import List from warchron.constants import ContextType from warchron.model.exception import ForbiddenOperation @@ -78,24 +77,12 @@ class ClosureService: # War methods @staticmethod - def close_war(war: War) -> List[str]: + def check_war_closable(war: War) -> None: + if war.is_over: + raise ForbiddenOperation("War already closed") if not war.all_campaigns_finished(): - raise RuntimeError("All campaigns must be finished to close their war") - ties: List[str] = [] - # for campaign in war.campaigns: - # # compute score - # # if participants have same score - # ties.append( - # ResolutionContext( - # context_type=ContextType.WAR, - # context_id=war.id, - # participant_ids=[ - # war.participants[war_participant_id], - # war.participants[war_participant_id], - # ], - # ) - # ) - if ties: - return ties + raise ForbiddenOperation("All campaigns must be closed to close their war") + + @staticmethod + def finalize_war(war: War) -> None: war.is_over = True - return [] diff --git a/src/warchron/model/score_service.py b/src/warchron/model/score_service.py index 0fb98b5..72d370c 100644 --- a/src/warchron/model/score_service.py +++ b/src/warchron/model/score_service.py @@ -24,14 +24,12 @@ class ScoreService: if not rnd.is_over: continue yield from rnd.battles.values() - elif context_type == ContextType.CAMPAIGN: campaign = war.get_campaign(context_id) for rnd in campaign.rounds: if not rnd.is_over: continue yield from rnd.battles.values() - elif context_type == ContextType.BATTLE: battle = war.get_battle(context_id) campaign = war.get_campaign_by_sector(battle.sector_id) diff --git a/src/warchron/model/tie_manager.py b/src/warchron/model/tie_manager.py index 12b291f..8ea0e75 100644 --- a/src/warchron/model/tie_manager.py +++ b/src/warchron/model/tie_manager.py @@ -82,7 +82,32 @@ class TieResolver: @staticmethod def find_war_ties(war: War) -> List[TieContext]: - return [] # TODO + if TieResolver.is_tie_resolved(war, ContextType.WAR, war.id): + return [] + scores = ScoreService.compute_scores(war, ContextType.WAR, war.id) + buckets: DefaultDict[int, List[str]] = defaultdict(list) + for pid, score in scores.items(): + buckets[score.victory_points].append(pid) + ties: List[TieContext] = [] + for score_value, participants in buckets.items(): + if len(participants) <= 1: + continue + 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, tie_id, participants + ): + war.events.append(TieResolved(None, ContextType.WAR, tie_id)) + continue + ties.append( + TieContext( + context_type=ContextType.WAR, + context_id=tie_id, + participants=participants, + ) + ) + return ties @staticmethod def apply_bids( diff --git a/src/warchron/view/view.py b/src/warchron/view/view.py index fd65300..5cbd294 100644 --- a/src/warchron/view/view.py +++ b/src/warchron/view/view.py @@ -13,12 +13,12 @@ from warchron.controller.dtos import ( ParticipantOption, TreeSelection, WarDTO, - WarParticipantDTO, ObjectiveDTO, SectorDTO, ChoiceDTO, BattleDTO, CampaignParticipantScoreDTO, + WarParticipantScoreDTO, ) from warchron.view.helpers import ( format_campaign_label, @@ -362,16 +362,35 @@ class View(QtWidgets.QMainWindow, Ui_MainWindow): table.setItem(row, 1, desc_item) table.resizeColumnsToContents() - def display_war_participants(self, participants: List[WarParticipantDTO]) -> None: + def display_war_participants( + self, + participants: List[WarParticipantScoreDTO], + objectives: List[ObjectiveDTO], + ) -> None: table = self.warParticipantsTable table.clearContents() + base_cols = ["Player", "Faction", "Victory"] + headers = base_cols + [obj.name for obj in objectives] + table.setColumnCount(len(headers)) + table.setHorizontalHeaderLabels(headers) table.setRowCount(len(participants)) + table.setIconSize(QSize(48, 16)) for row, part in enumerate(participants): name_item = QtWidgets.QTableWidgetItem(part.player_name) - fact_item = QtWidgets.QTableWidgetItem(part.faction) - name_item.setData(Qt.ItemDataRole.UserRole, part.id) + if part.rank_icon: + name_item.setIcon(part.rank_icon) + faction_item = QtWidgets.QTableWidgetItem(part.faction) + VP_item = QtWidgets.QTableWidgetItem(str(part.victory_points)) + name_item.setData(Qt.ItemDataRole.UserRole, part.war_participant_id) table.setItem(row, 0, name_item) - table.setItem(row, 1, fact_item) + table.setItem(row, 1, faction_item) + table.setItem(row, 2, VP_item) + col = 3 + for obj in objectives: + value = part.narrative_points.get(obj.id, 0) + NP_item = QtWidgets.QTableWidgetItem(str(value)) + table.setItem(row, col, NP_item) + col += 1 table.resizeColumnsToContents() def _on_major_changed(self, value: int) -> None: