diff --git a/src/warchron/controller/app_controller.py b/src/warchron/controller/app_controller.py index d0212db..276f73b 100644 --- a/src/warchron/controller/app_controller.py +++ b/src/warchron/controller/app_controller.py @@ -58,6 +58,7 @@ class AppController: ) self.view.endCampaignBtn.clicked.connect(self.campaigns.close_campaign) self.view.endRoundBtn.clicked.connect(self.rounds.close_round) + self.view.resolvePairingBtn.clicked.connect(self.rounds.resolve_pairing) self.view.on_add_item = self.add_item self.view.on_edit_item = self.edit_item self.view.on_delete_item = self.delete_item diff --git a/src/warchron/controller/closure_workflow.py b/src/warchron/controller/closure_workflow.py index fea0724..75100d3 100644 --- a/src/warchron/controller/closure_workflow.py +++ b/src/warchron/controller/closure_workflow.py @@ -7,6 +7,7 @@ from warchron.model.campaign import Campaign from warchron.model.round import Round from warchron.model.closure_service import ClosureService from warchron.model.tie_manager import TieResolver +from warchron.model.pairing import Pairing class ClosureWorkflow: @@ -94,3 +95,21 @@ class WarClosureWorkflow(ClosureWorkflow): objective_id, ) ClosureService.finalize_war(war) + + +class RoundPairingWorkflow: + + def __init__(self, controller: "AppController"): + self.app = controller + + 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) diff --git a/src/warchron/controller/round_controller.py b/src/warchron/controller/round_controller.py index f49b416..beeac04 100644 --- a/src/warchron/controller/round_controller.py +++ b/src/warchron/controller/round_controller.py @@ -5,7 +5,11 @@ from PyQt6.QtWidgets import QMessageBox from PyQt6.QtGui import QIcon from warchron.constants import ItemType, RefreshScope, Icons, IconName, ContextType -from warchron.model.exception import ForbiddenOperation, DomainError +from warchron.model.exception import ( + ForbiddenOperation, + DomainError, + RequiresConfirmation, +) from warchron.model.tie_manager import TieResolver, TieContext from warchron.model.result_checker import ResultChecker from warchron.model.round import Round @@ -20,7 +24,10 @@ from warchron.controller.dtos import ( ChoiceDTO, BattleDTO, ) -from warchron.controller.closure_workflow import RoundClosureWorkflow +from warchron.controller.closure_workflow import ( + RoundClosureWorkflow, + RoundPairingWorkflow, +) from warchron.view.choice_dialog import ChoiceDialog from warchron.view.battle_dialog import BattleDialog from warchron.view.tie_dialog import TieDialog @@ -176,6 +183,39 @@ class RoundController: RefreshScope.WARS_TREE, item_type=ItemType.ROUND, item_id=round_id ) + def resolve_pairing(self) -> None: + round_id = self.app.navigation.selected_round_id + if not round_id: + return + rnd = self.app.model.get_round(round_id) + war = self.app.model.get_war_by_round(round_id) + workflow = RoundPairingWorkflow(self.app) + try: + workflow.start(war, rnd) + except DomainError as e: + QMessageBox.warning( + self.app.view, + "Closure forbidden", + str(e), + ) + return + except RequiresConfirmation as e: + reply = QMessageBox.question( + self.app.view, + "Confirm pairing", + 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.ROUND, item_id=round_id + ) + def resolve_ties( self, war: War, contexts: List[TieContext] ) -> Dict[tuple[str, str, int | None], Dict[str, bool]]: diff --git a/src/warchron/model/battle.py b/src/warchron/model/battle.py index c63c1f4..17fb4cf 100644 --- a/src/warchron/model/battle.py +++ b/src/warchron/model/battle.py @@ -1,5 +1,5 @@ from __future__ import annotations -from typing import Any, Dict +from typing import Any, Dict, List from warchron.model.json_helper import JsonHelper @@ -55,6 +55,27 @@ class Battle: return True return False + def get_available_places(self) -> List[str]: + places: list[str] = [] + if self.player_1_id is None: + places.append("player_1") + if self.player_2_id is None: + places.append("player_2") + return places + + def assign_participant(self, participant_id: str) -> None: + if self.player_1_id is None: + self.player_1_id = participant_id + return + if self.player_2_id is None: + self.player_2_id = participant_id + return + raise RuntimeError("Battle has no available places") + + def cleanup_battle_players(self) -> None: + self.player_1_id = None + self.player_2_id = None + def is_finished(self) -> bool: return self.winner_id is not None or self.is_draw() diff --git a/src/warchron/model/campaign.py b/src/warchron/model/campaign.py index f482412..c5004aa 100644 --- a/src/warchron/model/campaign.py +++ b/src/warchron/model/campaign.py @@ -91,6 +91,15 @@ class Campaign: except KeyError: raise KeyError(f"Participant {participant_id} not in campaign {self.id}") + def get_campaign_participant_by_war_participant_id( + self, + war_participant_id: str, + ) -> CampaignParticipant | None: + for cp in self.participants.values(): + if cp.war_participant_id == war_participant_id: + return cp + return None + def get_all_campaign_participants(self) -> List[CampaignParticipant]: return list(self.participants.values()) diff --git a/src/warchron/model/pairing.py b/src/warchron/model/pairing.py new file mode 100644 index 0000000..218b757 --- /dev/null +++ b/src/warchron/model/pairing.py @@ -0,0 +1,118 @@ +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" + ) diff --git a/src/warchron/model/round.py b/src/warchron/model/round.py index 08d77d6..df9c927 100644 --- a/src/warchron/model/round.py +++ b/src/warchron/model/round.py @@ -1,6 +1,6 @@ from __future__ import annotations from uuid import uuid4 -from typing import Any, Dict +from typing import Any, Dict, List from warchron.model.exception import ForbiddenOperation from warchron.model.choice import Choice @@ -119,6 +119,11 @@ class Round: b.winner_id is not None or b.is_draw() for b in self.battles.values() ) + def get_battles_with_places(self) -> List[Battle]: + return [ + battle for battle in self.battles.values() if battle.get_available_places() + ] + def create_battle(self, sector_id: str) -> Battle: if self.is_over: raise ForbiddenOperation("Can't create battle in a closed round.") diff --git a/src/warchron/model/score_service.py b/src/warchron/model/score_service.py index 72d370c..aad36d9 100644 --- a/src/warchron/model/score_service.py +++ b/src/warchron/model/score_service.py @@ -1,5 +1,6 @@ -from typing import Dict, Iterator +from typing import Dict, Iterator, List, Callable from dataclasses import dataclass, field +from collections import defaultdict from warchron.constants import ContextType from warchron.model.war import War @@ -74,3 +75,15 @@ class ScoreService: sector.minor_objective_id ] += war.minor_value return scores + + @staticmethod + def group_participants_by_score( + scores: Dict[str, ParticipantScore], + value_getter: Callable[[ParticipantScore], int], + ) -> List[List[str]]: + groups: Dict[int, List[str]] = defaultdict(list) + for pid, score in scores.items(): + value = value_getter(score) + groups[value].append(pid) + ordered_values = sorted(groups.keys(), reverse=True) + return [groups[value] for value in ordered_values] diff --git a/src/warchron/model/tie_manager.py b/src/warchron/model/tie_manager.py index f63c651..b32be90 100644 --- a/src/warchron/model/tie_manager.py +++ b/src/warchron/model/tie_manager.py @@ -24,6 +24,71 @@ 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) diff --git a/src/warchron/view/ui/ui_main_window.py b/src/warchron/view/ui/ui_main_window.py index 227d449..038e890 100644 --- a/src/warchron/view/ui/ui_main_window.py +++ b/src/warchron/view/ui/ui_main_window.py @@ -379,7 +379,7 @@ class Ui_MainWindow(object): spacerItem13 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) self.horizontalLayout_13.addItem(spacerItem13) self.resolvePairingBtn = QtWidgets.QPushButton(parent=self.pageRound) - self.resolvePairingBtn.setEnabled(False) + self.resolvePairingBtn.setEnabled(True) self.resolvePairingBtn.setObjectName("resolvePairingBtn") self.horizontalLayout_13.addWidget(self.resolvePairingBtn) self.verticalLayout_8.addLayout(self.horizontalLayout_13) @@ -426,7 +426,7 @@ class Ui_MainWindow(object): self.gridLayout_2.addWidget(self.tabWidget, 0, 0, 1, 1) MainWindow.setCentralWidget(self.centralwidget) self.menubar = QtWidgets.QMenuBar(parent=MainWindow) - self.menubar.setGeometry(QtCore.QRect(0, 0, 849, 21)) + self.menubar.setGeometry(QtCore.QRect(0, 0, 849, 22)) self.menubar.setObjectName("menubar") self.menuFile = QtWidgets.QMenu(parent=self.menubar) self.menuFile.setObjectName("menuFile") @@ -476,7 +476,7 @@ class Ui_MainWindow(object): self.retranslateUi(MainWindow) self.tabWidget.setCurrentIndex(1) - self.selectedDetailsStack.setCurrentIndex(0) + self.selectedDetailsStack.setCurrentIndex(3) QtCore.QMetaObject.connectSlotsByName(MainWindow) def retranslateUi(self, MainWindow): diff --git a/src/warchron/view/ui/ui_main_window.ui b/src/warchron/view/ui/ui_main_window.ui index 1650d28..dd8c0d6 100644 --- a/src/warchron/view/ui/ui_main_window.ui +++ b/src/warchron/view/ui/ui_main_window.ui @@ -157,7 +157,7 @@ - 0 + 3 @@ -854,7 +854,7 @@ - false + true Resolve pairing @@ -967,7 +967,7 @@ 0 0 849 - 21 + 22