From 3c54a8d4a77bde2b0dc0d22c9827e110d42f12cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20R=C3=A9aux?= Date: Tue, 24 Feb 2026 15:40:24 +0100 Subject: [PATCH 01/10] fix inluence_token setting auto cleanup and fix is_dirty --- src/warchron/controller/app_controller.py | 25 +++++-- .../controller/campaign_controller.py | 3 +- src/warchron/controller/round_controller.py | 3 +- src/warchron/controller/war_controller.py | 26 ++++++- src/warchron/model/war.py | 75 +++++++++++++++++-- 5 files changed, 114 insertions(+), 18 deletions(-) diff --git a/src/warchron/controller/app_controller.py b/src/warchron/controller/app_controller.py index 394d04b..63909de 100644 --- a/src/warchron/controller/app_controller.py +++ b/src/warchron/controller/app_controller.py @@ -215,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, - "Deletion forbidden", + "Add forbidden", str(e), ) + return except RequiresConfirmation as e: reply = QMessageBox.question( self.view, @@ -231,7 +231,10 @@ class AppController: ) if reply == QMessageBox.StandardButton.Yes: e.action() - self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) + else: + return + self.is_dirty = True + self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) def edit_item(self, item_type: str, item_id: str) -> None: try: @@ -266,13 +269,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, - "Deletion forbidden", + "Update forbidden", str(e), ) + return except RequiresConfirmation as e: reply = QMessageBox.question( self.view, @@ -282,7 +285,10 @@ class AppController: ) if reply == QMessageBox.StandardButton.Yes: e.action() - self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) + else: + return + self.is_dirty = True + self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) def delete_item(self, item_type: str, item_id: str) -> None: reply = QMessageBox.question( @@ -326,13 +332,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, @@ -342,4 +348,7 @@ class AppController: ) if reply == QMessageBox.StandardButton.Yes: e.action() - self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) + else: + return + self.is_dirty = True + self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) diff --git a/src/warchron/controller/campaign_controller.py b/src/warchron/controller/campaign_controller.py index d66154e..147bd93 100644 --- a/src/warchron/controller/campaign_controller.py +++ b/src/warchron/controller/campaign_controller.py @@ -177,9 +177,10 @@ class CampaignController: except DomainError as e: QMessageBox.warning( self.app.view, - "Deletion forbidden", + "Closure forbidden", str(e), ) + return self.app.is_dirty = True self.app.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) self.app.navigation.refresh_and_select( diff --git a/src/warchron/controller/round_controller.py b/src/warchron/controller/round_controller.py index 8fdc4c9..dcfcea3 100644 --- a/src/warchron/controller/round_controller.py +++ b/src/warchron/controller/round_controller.py @@ -167,9 +167,10 @@ class RoundController: except DomainError as e: QMessageBox.warning( self.app.view, - "Deletion forbidden", + "Closure forbidden", str(e), ) + return self.app.is_dirty = True self.app.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) self.app.navigation.refresh_and_select( diff --git a/src/warchron/controller/war_controller.py b/src/warchron/controller/war_controller.py index 54f28f6..5ebf567 100644 --- a/src/warchron/controller/war_controller.py +++ b/src/warchron/controller/war_controller.py @@ -11,7 +11,11 @@ from warchron.constants import ( IconName, RANK_TO_ICON, ) -from warchron.model.exception import DomainError, ForbiddenOperation +from warchron.model.exception import ( + DomainError, + ForbiddenOperation, + RequiresConfirmation, +) if TYPE_CHECKING: from warchron.controller.app_controller import AppController @@ -152,9 +156,10 @@ class WarController: except DomainError as e: QMessageBox.warning( self.app.view, - "Deletion forbidden", + "Closure forbidden", str(e), ) + return self.app.is_dirty = True self.app.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) self.app.navigation.refresh_and_select( @@ -200,6 +205,7 @@ class WarController: "Setting forbidden", str(e), ) + return self.app.is_dirty = True self.app.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) @@ -215,6 +221,7 @@ class WarController: "Setting forbidden", str(e), ) + return self.app.is_dirty = True self.app.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) @@ -230,8 +237,23 @@ 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/war.py b/src/warchron/model/war.py index 8681d12..b6bf57c 100644 --- a/src/warchron/model/war.py +++ b/src/warchron/model/war.py @@ -3,8 +3,14 @@ from uuid import uuid4 from datetime import datetime from typing import Any, Dict, List -from warchron.model.war_event import WarEvent, InfluenceGained, InfluenceSpent -from warchron.model.exception import ForbiddenOperation +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_participant import WarParticipant from warchron.model.objective import Objective from warchron.model.campaign_participant import CampaignParticipant @@ -55,10 +61,67 @@ 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.") - # 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 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 + # TODO Reopen rounds with battle on sector with influence objective + 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 + + 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, + ) def set_state(self, new_state: bool) -> None: self.is_over = new_state From 765c691b59622ea010a13c3426e4e5ab451d1d25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20R=C3=A9aux?= Date: Tue, 24 Feb 2026 15:48:19 +0100 Subject: [PATCH 02/10] fix spent token on battle tie cancel --- src/warchron/controller/round_controller.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/warchron/controller/round_controller.py b/src/warchron/controller/round_controller.py index dcfcea3..13bb664 100644 --- a/src/warchron/controller/round_controller.py +++ b/src/warchron/controller/round_controller.py @@ -198,6 +198,7 @@ class RoundController: context_id=ctx.context_id, ) if not dialog.exec(): + TieResolver.cancel_tie_break(war, ContextType.BATTLE, ctx.context_id) raise ForbiddenOperation("Tie resolution cancelled") bids_map[ctx.context_id] = dialog.get_bids() return bids_map From 5c124f9229dfbf590ed4e065f477ee0432a70c9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20R=C3=A9aux?= Date: Wed, 25 Feb 2026 14:37:59 +0100 Subject: [PATCH 03/10] Fix refresh on new/open file --- src/warchron/controller/app_controller.py | 4 +-- .../controller/navigation_controller.py | 26 +++++++++++++++++-- src/warchron/model/war.py | 2 -- src/warchron/view/view.py | 13 ++++++---- 4 files changed, 33 insertions(+), 12 deletions(-) diff --git a/src/warchron/controller/app_controller.py b/src/warchron/controller/app_controller.py index 63909de..7f0bf9b 100644 --- a/src/warchron/controller/app_controller.py +++ b/src/warchron/controller/app_controller.py @@ -96,7 +96,6 @@ 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: @@ -117,7 +116,6 @@ 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: @@ -287,7 +285,7 @@ class AppController: e.action() else: return - self.is_dirty = True + self.is_dirty = True self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) def delete_item(self, item_type: str, item_id: str) -> None: diff --git a/src/warchron/controller/navigation_controller.py b/src/warchron/controller/navigation_controller.py index fe84943..2a6aa47 100644 --- a/src/warchron/controller/navigation_controller.py +++ b/src/warchron/controller/navigation_controller.py @@ -31,7 +31,8 @@ class NavigationController: self.app.view.display_players(players_for_display) def refresh_wars_view(self) -> None: - wars: List[WarDTO] = [ + wars = self.app.model.get_all_wars() + wars_dto: List[WarDTO] = [ WarDTO( id=w.id, name=w.name, @@ -57,7 +58,21 @@ class NavigationController: ) for w in self.app.model.get_all_wars() ] - self.app.view.display_wars_tree(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() def refresh(self, scope: RefreshScope) -> None: match scope: @@ -87,6 +102,13 @@ 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/model/war.py b/src/warchron/model/war.py index b6bf57c..a7db99e 100644 --- a/src/warchron/model/war.py +++ b/src/warchron/model/war.py @@ -88,7 +88,6 @@ class War: else: new_events.append(ev) self.events = new_events - # TODO Reopen rounds with battle on sector with influence objective for camp in self.campaigns: for sect in camp.get_all_sectors(): if sect.influence_objective_id is not None: @@ -107,7 +106,6 @@ class War: ) 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 diff --git a/src/warchron/view/view.py b/src/warchron/view/view.py index 5e87ce3..31354a1 100644 --- a/src/warchron/view/view.py +++ b/src/warchron/view/view.py @@ -90,6 +90,7 @@ 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: @@ -143,6 +144,11 @@ 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: @@ -237,10 +243,7 @@ class View(QtWidgets.QMainWindow, Ui_MainWindow): def display_wars_tree(self, wars: List[WarDTO]) -> None: tree = self.warsTree - try: - tree.currentItemChanged.disconnect() - except TypeError: - pass + tree.blockSignals(True) tree.clear() tree.setColumnCount(1) tree.setHeaderLabels(["Wars"]) @@ -264,8 +267,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: From 6efd22527add30159398f0f89175d5b139fc5ac3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20R=C3=A9aux?= Date: Wed, 25 Feb 2026 16:54:21 +0100 Subject: [PATCH 04/10] fix re-enable token on closed camapign + refacto war_event attributes --- .../controller/campaign_controller.py | 17 +++-- src/warchron/controller/closure_workflow.py | 18 ++--- src/warchron/controller/round_controller.py | 12 ++-- src/warchron/controller/war_controller.py | 22 ++++-- src/warchron/model/closure_service.py | 5 +- src/warchron/model/result_checker.py | 7 +- src/warchron/model/tie_manager.py | 67 +++++++++++++------ src/warchron/model/war_event.py | 17 ++++- 8 files changed, 108 insertions(+), 57 deletions(-) diff --git a/src/warchron/controller/campaign_controller.py b/src/warchron/controller/campaign_controller.py index 147bd93..125d59b 100644 --- a/src/warchron/controller/campaign_controller.py +++ b/src/warchron/controller/campaign_controller.py @@ -1,4 +1,4 @@ -from typing import List, Dict, TYPE_CHECKING +from typing import List, Dict, Tuple, TYPE_CHECKING from PyQt6.QtWidgets import QMessageBox, QDialog from PyQt6.QtGui import QIcon @@ -54,9 +54,8 @@ class CampaignController: 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 + war, ContextType.CAMPAIGN, campaign.id, scores[group[0]].victory_points ) for pid in group: spent = token_map.get(pid, 0) @@ -189,7 +188,7 @@ class CampaignController: def resolve_ties( self, war: War, contexts: List[TieContext] - ) -> Dict[str, Dict[str, bool]]: + ) -> Dict[Tuple[ContextType, str, int | None], Dict[str, bool]]: bids_map = {} for ctx in contexts: active = TieResolver.get_active_participants( @@ -208,11 +207,17 @@ class CampaignController: context_id=ctx.context_id, ) if not dialog.exec(): - TieResolver.cancel_tie_break(war, ContextType.CAMPAIGN, ctx.context_id) + TieResolver.cancel_tie_break( + war, ContextType.CAMPAIGN, ctx.context_id, ctx.score_value + ) raise ForbiddenOperation("Tie resolution cancelled") - bids_map[ctx.context_id] = dialog.get_bids() + bids_map[(ctx.context_type, ctx.context_id, ctx.score_value)] = ( + 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 diff --git a/src/warchron/controller/closure_workflow.py b/src/warchron/controller/closure_workflow.py index d668338..fe368ac 100644 --- a/src/warchron/controller/closure_workflow.py +++ b/src/warchron/controller/closure_workflow.py @@ -24,11 +24,9 @@ class RoundClosureWorkflow(ClosureWorkflow): while ties: bids_map = self.app.rounds.resolve_ties(war, ties) for tie in ties: - bids = bids_map[tie.context_id] + bids = bids_map[(tie.context_type, tie.context_id, tie.score_value)] 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 - ) + TieResolver.resolve_tie_state(war, tie, bids) ties = TieResolver.find_battle_ties(war, round.id) for battle in round.battles.values(): ClosureService.apply_battle_outcomes(war, campaign, battle) @@ -43,11 +41,9 @@ class CampaignClosureWorkflow(ClosureWorkflow): while ties: bids_map = self.app.campaigns.resolve_ties(war, ties) for tie in ties: - bids = bids_map[tie.context_id] + bids = bids_map[(tie.context_type, tie.context_id, tie.score_value)] 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 - ) + TieResolver.resolve_tie_state(war, tie, bids) ties = TieResolver.find_campaign_ties(war, campaign.id) ClosureService.finalize_campaign(campaign) @@ -60,10 +56,8 @@ class WarClosureWorkflow(ClosureWorkflow): while ties: bids_map = self.app.wars.resolve_ties(war, ties) for tie in ties: - bids = bids_map[tie.context_id] + bids = bids_map[(tie.context_type, tie.context_id, tie.score_value)] 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 - ) + TieResolver.resolve_tie_state(war, tie, bids) ties = TieResolver.find_war_ties(war) ClosureService.finalize_war(war) diff --git a/src/warchron/controller/round_controller.py b/src/warchron/controller/round_controller.py index 13bb664..5af114b 100644 --- a/src/warchron/controller/round_controller.py +++ b/src/warchron/controller/round_controller.py @@ -1,4 +1,4 @@ -from typing import List, Dict, TYPE_CHECKING +from typing import List, Dict, Tuple, TYPE_CHECKING from PyQt6.QtWidgets import QDialog from PyQt6.QtWidgets import QMessageBox @@ -179,7 +179,7 @@ class RoundController: def resolve_ties( self, war: War, contexts: List[TieContext] - ) -> Dict[str, Dict[str, bool]]: + ) -> Dict[Tuple[ContextType, str, int | None], Dict[str, bool]]: bids_map = {} for ctx in contexts: players = [ @@ -198,9 +198,13 @@ class RoundController: context_id=ctx.context_id, ) if not dialog.exec(): - TieResolver.cancel_tie_break(war, ContextType.BATTLE, ctx.context_id) + TieResolver.cancel_tie_break( + war, ContextType.BATTLE, ctx.context_id, ctx.score_value + ) raise ForbiddenOperation("Tie resolution cancelled") - bids_map[ctx.context_id] = dialog.get_bids() + bids_map[(ctx.context_type, ctx.context_id, ctx.score_value)] = ( + 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 5ebf567..c950363 100644 --- a/src/warchron/controller/war_controller.py +++ b/src/warchron/controller/war_controller.py @@ -1,4 +1,4 @@ -from typing import List, TYPE_CHECKING, Dict +from typing import List, Tuple, TYPE_CHECKING, Dict from PyQt6.QtWidgets import QMessageBox, QDialog from PyQt6.QtGui import QIcon @@ -53,8 +53,9 @@ class WarController: 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) + tie_resolved = TieResolver.is_tie_resolved( + war, ContextType.WAR, war.id, scores[group[0]].victory_points + ) for pid in group: spent = token_map.get(pid, 0) if not tie_resolved and spent == 0: @@ -169,11 +170,14 @@ class WarController: # TODO fix ignored campaign tie-breaks def resolve_ties( self, war: War, contexts: List[TieContext] - ) -> Dict[str, Dict[str, bool]]: + ) -> Dict[Tuple[ContextType, str, int | None], 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)) @@ -188,9 +192,13 @@ class WarController: context_id=ctx.context_id, ) if not dialog.exec(): - TieResolver.cancel_tie_break(war, ContextType.WAR, ctx.context_id) + TieResolver.cancel_tie_break( + war, ContextType.WAR, ctx.context_id, ctx.score_value + ) raise ForbiddenOperation("Tie resolution cancelled") - bids_map[ctx.context_id] = dialog.get_bids() + bids_map[(ctx.context_type, ctx.context_id, ctx.score_value)] = ( + dialog.get_bids() + ) return bids_map def set_major_value(self, value: int) -> None: diff --git a/src/warchron/model/closure_service.py b/src/warchron/model/closure_service.py index 21a3b5f..0cc16f3 100644 --- a/src/warchron/model/closure_service.py +++ b/src/warchron/model/closure_service.py @@ -27,8 +27,7 @@ class ClosureService: from warchron.model.result_checker import ResultChecker already_granted = any( - isinstance(e, InfluenceGained) - and e.context_id == f"battle:{battle.sector_id}" + isinstance(e, InfluenceGained) and e.context_id == battle.sector_id for e in war.events ) if already_granted: @@ -51,7 +50,7 @@ class ClosureService: participant_id=effective_winner, amount=1, context_type=ContextType.BATTLE, - context_id=f"battle:{battle.sector_id}", + context_id=battle.sector_id, ) ) diff --git a/src/warchron/model/result_checker.py b/src/warchron/model/result_checker.py index d69003a..a8c7661 100644 --- a/src/warchron/model/result_checker.py +++ b/src/warchron/model/result_checker.py @@ -45,10 +45,9 @@ class ResultChecker: current_rank = 1 for vp in sorted_vps: participants = vp_buckets[vp] - tie_id = f"{context_id}:score:{vp}" # no tie if len(participants) == 1 or not TieResolver.is_tie_resolved( - war, context_type, tie_id + war, context_type, context_id, vp ): ranking.append( (current_rank, participants, {pid: 0 for pid in participants}) @@ -59,11 +58,11 @@ class ResultChecker: groups = TieResolver.rank_by_tokens( war, context_type, - tie_id, + context_id, participants, ) tokens_spent = TieResolver.tokens_spent_map( - war, context_type, tie_id, participants + war, context_type, context_id, participants ) for group in groups: group_tokens = {pid: tokens_spent[pid] for pid in group} diff --git a/src/warchron/model/tie_manager.py b/src/warchron/model/tie_manager.py index c398592..4275ae2 100644 --- a/src/warchron/model/tie_manager.py +++ b/src/warchron/model/tie_manager.py @@ -14,6 +14,7 @@ class TieContext: context_type: ContextType context_id: str participants: List[str] # war_participant_ids + score_value: int | None = None class TieResolver: @@ -47,6 +48,7 @@ class TieResolver: context_type=ContextType.BATTLE, context_id=battle.sector_id, participants=[p1, p2], + score_value=None, ) ) return ties @@ -63,19 +65,23 @@ class TieResolver: for score_value, participants in buckets.items(): if len(participants) <= 1: continue - tie_id = f"{campaign_id}:score:{score_value}" - if TieResolver.is_tie_resolved(war, ContextType.CAMPAIGN, tie_id): + if TieResolver.is_tie_resolved( + war, ContextType.CAMPAIGN, campaign_id, score_value + ): continue if not TieResolver.can_tie_be_resolved( - war, ContextType.CAMPAIGN, tie_id, participants + war, ContextType.CAMPAIGN, campaign_id, participants ): - war.events.append(TieResolved(None, ContextType.CAMPAIGN, tie_id)) + war.events.append( + TieResolved(None, ContextType.CAMPAIGN, campaign_id, score_value) + ) continue ties.append( TieContext( context_type=ContextType.CAMPAIGN, - context_id=tie_id, + context_id=campaign_id, participants=participants, + score_value=score_value, ) ) return ties @@ -92,19 +98,21 @@ class TieResolver: 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): + if TieResolver.is_tie_resolved(war, ContextType.WAR, war.id, score_value): continue if not TieResolver.can_tie_be_resolved( - war, ContextType.WAR, tie_id, participants + war, ContextType.WAR, war.id, participants ): - war.events.append(TieResolved(None, ContextType.WAR, tie_id)) + war.events.append( + TieResolved(None, ContextType.WAR, war.id, score_value) + ) continue ties.append( TieContext( context_type=ContextType.WAR, - context_id=tie_id, + context_id=war.id, participants=participants, + score_value=score_value, ) ) return ties @@ -135,6 +143,7 @@ class TieResolver: war: War, context_type: ContextType, context_id: str, + score_value: int | None = None, ) -> None: war.events = [ ev @@ -149,6 +158,7 @@ class TieResolver: isinstance(ev, TieResolved) and ev.context_type == context_type and ev.context_id == context_id + and ev.score_value == score_value ) ) ] @@ -210,26 +220,37 @@ class TieResolver: @staticmethod def resolve_tie_state( war: War, - context_type: ContextType, - context_id: str, - participants: List[str], + ctx: TieContext, bids: dict[str, bool] | None = None, ) -> None: active = TieResolver.get_active_participants( - war, context_type, context_id, participants + war, + ctx.context_type, + ctx.context_id, + ctx.participants, ) # confirmed draw if non had bid if not active: - war.events.append(TieResolved(None, context_type, context_id)) + war.events.append( + TieResolved(None, ctx.context_type, ctx.context_id, ctx.score_value) + ) return # confirmed draw if current bids are 0 if bids is not None and not any(bids.values()): - war.events.append(TieResolved(None, context_type, context_id)) + war.events.append( + TieResolved(None, ctx.context_type, ctx.context_id, ctx.score_value) + ) return # else rank_by_tokens - groups = TieResolver.rank_by_tokens(war, context_type, context_id, participants) + groups = TieResolver.rank_by_tokens( + war, ctx.context_type, ctx.context_id, ctx.participants + ) if len(groups[0]) == 1: - war.events.append(TieResolved(groups[0][0], context_type, context_id)) + war.events.append( + TieResolved( + groups[0][0], ctx.context_type, ctx.context_id, ctx.score_value + ) + ) return # if tie persists, do nothing, workflow will call again @@ -247,21 +268,29 @@ 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) -> bool: + def is_tie_resolved( + war: War, + context_type: ContextType, + context_id: str, + score_value: int | None = None, + ) -> 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_event.py b/src/warchron/model/war_event.py index 62209cd..3f3f4db 100644 --- a/src/warchron/model/war_event.py +++ b/src/warchron/model/war_event.py @@ -63,19 +63,32 @@ class WarEvent: class TieResolved(WarEvent): TYPE = "TieResolved" - def __init__(self, participant_id: str | None, context_type: str, context_id: str): + def __init__( + self, + participant_id: str | None, + context_type: str, + context_id: str, + score_value: int | None = None, + ): 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"], + data["participant_id"] or None, data["context_type"], data["context_id"], + data["score_value"] or None, ) return cls._base_fromDict(ev, data) From 747f5dec65e83234d0de4d48fbce973c7895bbb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20R=C3=A9aux?= Date: Wed, 25 Feb 2026 16:58:12 +0100 Subject: [PATCH 05/10] fix keep re-enable token state --- src/warchron/model/war.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/warchron/model/war.py b/src/warchron/model/war.py index a7db99e..360d70e 100644 --- a/src/warchron/model/war.py +++ b/src/warchron/model/war.py @@ -93,6 +93,7 @@ class War: 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: From 58589b8dc18d75414511fc66aea64ce26c10619d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20R=C3=A9aux?= Date: Thu, 26 Feb 2026 10:36:13 +0100 Subject: [PATCH 06/10] fix tie-break & draw icons in war & campaign ranking --- .../controller/campaign_controller.py | 26 ++++++++++--------- src/warchron/controller/war_controller.py | 25 ++++++++++-------- src/warchron/model/tie_manager.py | 3 +++ 3 files changed, 31 insertions(+), 23 deletions(-) diff --git a/src/warchron/controller/campaign_controller.py b/src/warchron/controller/campaign_controller.py index 125d59b..cc62e0c 100644 --- a/src/warchron/controller/campaign_controller.py +++ b/src/warchron/controller/campaign_controller.py @@ -54,21 +54,24 @@ class CampaignController: icon_map = {} for rank, group, token_map in ranking: base_icon = RANK_TO_ICON.get(rank, IconName.VPNTH) - tie_resolved = TieResolver.is_tie_resolved( - war, ContextType.CAMPAIGN, campaign.id, scores[group[0]].victory_points + 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 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: + 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 @@ -276,7 +279,6 @@ 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/war_controller.py b/src/warchron/controller/war_controller.py index c950363..5cd0de0 100644 --- a/src/warchron/controller/war_controller.py +++ b/src/warchron/controller/war_controller.py @@ -53,21 +53,24 @@ class WarController: icon_map = {} for rank, group, token_map in ranking: base_icon = RANK_TO_ICON.get(rank, IconName.VPNTH) - tie_resolved = TieResolver.is_tie_resolved( - war, ContextType.WAR, war.id, scores[group[0]].victory_points + 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 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: + 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/model/tie_manager.py b/src/warchron/model/tie_manager.py index 4275ae2..65eea80 100644 --- a/src/warchron/model/tie_manager.py +++ b/src/warchron/model/tie_manager.py @@ -27,6 +27,7 @@ class TieResolver: for battle in round.battles.values(): if not battle.is_draw(): continue + # TODO remove test without score if TieResolver.is_tie_resolved(war, ContextType.BATTLE, battle.sector_id): continue @@ -55,6 +56,7 @@ class TieResolver: @staticmethod def find_campaign_ties(war: War, campaign_id: str) -> List[TieContext]: + # TODO remove test without score if TieResolver.is_tie_resolved(war, ContextType.CAMPAIGN, campaign_id): return [] scores = ScoreService.compute_scores(war, ContextType.CAMPAIGN, campaign_id) @@ -88,6 +90,7 @@ class TieResolver: @staticmethod def find_war_ties(war: War) -> List[TieContext]: + # TODO remove test without score if TieResolver.is_tie_resolved(war, ContextType.WAR, war.id): return [] scores = ScoreService.compute_scores(war, ContextType.WAR, war.id) From 5a64a294c5dc482de12156b0ff239294a4e4a4d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20R=C3=A9aux?= Date: Thu, 26 Feb 2026 10:55:28 +0100 Subject: [PATCH 07/10] finish score_value refacto using is_tie_resolved --- src/warchron/model/tie_manager.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/warchron/model/tie_manager.py b/src/warchron/model/tie_manager.py index 65eea80..d714f4a 100644 --- a/src/warchron/model/tie_manager.py +++ b/src/warchron/model/tie_manager.py @@ -27,10 +27,10 @@ class TieResolver: for battle in round.battles.values(): if not battle.is_draw(): continue - # TODO remove test without score - if TieResolver.is_tie_resolved(war, ContextType.BATTLE, battle.sector_id): + if TieResolver.is_tie_resolved( + war, ContextType.BATTLE, battle.sector_id, None + ): 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: @@ -56,9 +56,6 @@ class TieResolver: @staticmethod def find_campaign_ties(war: War, campaign_id: str) -> List[TieContext]: - # TODO remove test without score - 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(): @@ -90,9 +87,6 @@ class TieResolver: @staticmethod def find_war_ties(war: War) -> List[TieContext]: - # TODO remove test without score - 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(): From 3fe3bb331c4b7939eff34af791aa69e2094a2611 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20R=C3=A9aux?= Date: Thu, 26 Feb 2026 11:28:29 +0100 Subject: [PATCH 08/10] factorise ranking icon mapper --- .../controller/campaign_controller.py | 45 ++-------------- src/warchron/controller/ranking_icon.py | 54 +++++++++++++++++++ src/warchron/controller/war_controller.py | 41 +------------- 3 files changed, 60 insertions(+), 80 deletions(-) create mode 100644 src/warchron/controller/ranking_icon.py diff --git a/src/warchron/controller/campaign_controller.py b/src/warchron/controller/campaign_controller.py index cc62e0c..88199eb 100644 --- a/src/warchron/controller/campaign_controller.py +++ b/src/warchron/controller/campaign_controller.py @@ -1,15 +1,11 @@ from typing import List, Dict, Tuple, 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: @@ -28,8 +24,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 @@ -40,41 +36,6 @@ 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) - 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 - 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) @@ -98,7 +59,9 @@ class CampaignController: rows: List[CampaignParticipantScoreDTO] = [] icon_map = {} if camp.is_over: - icon_map = self._compute_campaign_ranking_icons(war, camp) + icon_map = RankingIcon.compute_icons( + war, ContextType.CAMPAIGN, campaign_id, scores + ) 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) diff --git a/src/warchron/controller/ranking_icon.py b/src/warchron/controller/ranking_icon.py new file mode 100644 index 0000000..bb7d914 --- /dev/null +++ b/src/warchron/controller/ranking_icon.py @@ -0,0 +1,54 @@ +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/war_controller.py b/src/warchron/controller/war_controller.py index 5cd0de0..554630e 100644 --- a/src/warchron/controller/war_controller.py +++ b/src/warchron/controller/war_controller.py @@ -1,15 +1,11 @@ from typing import List, Tuple, 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, @@ -29,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 @@ -41,39 +37,6 @@ 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) - 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 - 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) @@ -92,7 +55,7 @@ class WarController: rows: List[WarParticipantScoreDTO] = [] icon_map = {} if war.is_over: - icon_map = self._compute_war_ranking_icons(war) + icon_map = RankingIcon.compute_icons(war, ContextType.WAR, war_id, scores) 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] From e7d3b962cad93cd847907b5c5be33be65c38cbf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20R=C3=A9aux?= Date: Thu, 26 Feb 2026 14:57:47 +0100 Subject: [PATCH 09/10] fix war tie ranking with bonus campaign ranking --- src/warchron/controller/war_controller.py | 2 +- src/warchron/model/result_checker.py | 65 +++++++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/src/warchron/controller/war_controller.py b/src/warchron/controller/war_controller.py index 554630e..ac2f0b4 100644 --- a/src/warchron/controller/war_controller.py +++ b/src/warchron/controller/war_controller.py @@ -133,7 +133,6 @@ class WarController: 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]]: @@ -150,6 +149,7 @@ class WarController: for pid in active ] counters = [war.get_influence_tokens(pid) for pid in active] + # TODO fix sorted participants included in tie-break after campaign ranking dialog = TieDialog( parent=self.app.view, players=players, diff --git a/src/warchron/model/result_checker.py b/src/warchron/model/result_checker.py index a8c7661..6430ef6 100644 --- a/src/warchron/model/result_checker.py +++ b/src/warchron/model/result_checker.py @@ -45,6 +45,39 @@ 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 # no tie if len(participants) == 1 or not TieResolver.is_tie_resolved( war, context_type, context_id, vp @@ -69,3 +102,35 @@ class ResultChecker: 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 From e64d9ff43b9670fbd8e7abd7b20c69e8d853d31a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20R=C3=A9aux?= Date: Thu, 26 Feb 2026 15:14:44 +0100 Subject: [PATCH 10/10] fix war tie campaign sorted unwanted participant --- src/warchron/controller/war_controller.py | 1 - src/warchron/model/tie_manager.py | 19 ++++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/warchron/controller/war_controller.py b/src/warchron/controller/war_controller.py index ac2f0b4..84977e9 100644 --- a/src/warchron/controller/war_controller.py +++ b/src/warchron/controller/war_controller.py @@ -149,7 +149,6 @@ class WarController: for pid in active ] counters = [war.get_influence_tokens(pid) for pid in active] - # TODO fix sorted participants included in tie-break after campaign ranking dialog = TieDialog( parent=self.app.view, players=players, diff --git a/src/warchron/model/tie_manager.py b/src/warchron/model/tie_manager.py index d714f4a..e174e8e 100644 --- a/src/warchron/model/tie_manager.py +++ b/src/warchron/model/tie_manager.py @@ -87,19 +87,20 @@ class TieResolver: @staticmethod def find_war_ties(war: War) -> List[TieContext]: + from warchron.model.result_checker import ResultChecker + 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) + ranking = ResultChecker.get_effective_ranking( + war, ContextType.WAR, war.id, scores + ) ties: List[TieContext] = [] - for score_value, participants in buckets.items(): - if len(participants) <= 1: + for _, group, _ in ranking: + if len(group) <= 1: continue + score_value = scores[group[0]].victory_points if TieResolver.is_tie_resolved(war, ContextType.WAR, war.id, score_value): continue - if not TieResolver.can_tie_be_resolved( - war, ContextType.WAR, war.id, participants - ): + if not TieResolver.can_tie_be_resolved(war, ContextType.WAR, war.id, group): war.events.append( TieResolved(None, ContextType.WAR, war.id, score_value) ) @@ -108,7 +109,7 @@ class TieResolver: TieContext( context_type=ContextType.WAR, context_id=war.id, - participants=participants, + participants=group, score_value=score_value, ) )