From aa75a5b84fab13dae3ed1141714929e8693da0ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20R=C3=A9aux?= Date: Wed, 18 Mar 2026 10:48:29 +0100 Subject: [PATCH 01/10] avoid choic tiebreak when sector places are enough for remaining active tied participants --- src/warchron/model/pairing.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/warchron/model/pairing.py b/src/warchron/model/pairing.py index 24e77f9..a6dcabd 100644 --- a/src/warchron/model/pairing.py +++ b/src/warchron/model/pairing.py @@ -203,8 +203,6 @@ class Pairing: active = TieResolver.get_active_participants( war, context, context.participants ) - if len(active) <= 1: - break current_context = TieContext( context_type=context.context_type, context_id=context.context_id, @@ -213,9 +211,12 @@ class Pairing: score_kind=context.score_kind, sector_id=context.sector_id, ) - # natural or unbreakable draw - if not TieResolver.can_tie_be_resolved( - war, context, current_context.participants + # natural, unbreakable or acceptable (enough places) draw + if ( + not TieResolver.can_tie_be_resolved( + war, context, current_context.participants + ) + or len(active) <= places ): war.events.append( TieResolved( From 9e602e8ca49aa59108c8000f49f96ecd7f9db099 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20R=C3=A9aux?= Date: Wed, 18 Mar 2026 14:30:57 +0100 Subject: [PATCH 02/10] tie dialog title with context details --- .../controller/campaign_controller.py | 16 ++---- src/warchron/controller/dtos.py | 5 ++ src/warchron/controller/presenter.py | 52 +++++++++++++++++++ src/warchron/controller/round_controller.py | 16 +++++- src/warchron/controller/war_controller.py | 17 +++--- src/warchron/model/score_service.py | 15 ++++-- src/warchron/model/war.py | 18 +++++-- src/warchron/view/tie_dialog.py | 16 +----- 8 files changed, 109 insertions(+), 46 deletions(-) create mode 100644 src/warchron/controller/presenter.py diff --git a/src/warchron/controller/campaign_controller.py b/src/warchron/controller/campaign_controller.py index 97586b2..42855c1 100644 --- a/src/warchron/controller/campaign_controller.py +++ b/src/warchron/controller/campaign_controller.py @@ -23,6 +23,7 @@ from warchron.model.tie_manager import TieContext, TieResolver from warchron.model.score_service import ScoreService from warchron.controller.closure_workflow import CampaignClosureWorkflow from warchron.controller.ranking_icon import RankingIcon +from warchron.controller.presenter import TiePresenter from warchron.view.campaign_dialog import CampaignDialog from warchron.view.campaign_participant_dialog import CampaignParticipantDialog from warchron.view.sector_dialog import SectorDialog @@ -176,24 +177,17 @@ class CampaignController: for pid in active ] counters = [war.get_influence_tokens(pid) for pid in active] + data = TiePresenter.build_dialog_data( + war, ctx, campaign=war.get_campaign(ctx.context_id) + ) dialog = TieDialog( parent=self.app.view, players=players, counters=counters, context_type=ctx.context_type, context_id=ctx.context_id, - context_name=None, + context_name=data.title, ) - if ctx.objective_id: - objective = war.objectives[ctx.objective_id] - dialog = TieDialog( - parent=self.app.view, - players=players, - counters=counters, - context_type=ctx.context_type, - context_id=ctx.context_id, - context_name=f"Objective tie: {objective.name}", - ) if not dialog.exec(): TieResolver.cancel_tie_break(war, ctx) raise AbortedOperation("Tie resolution cancelled") diff --git a/src/warchron/controller/dtos.py b/src/warchron/controller/dtos.py index 9451bc6..7882d08 100644 --- a/src/warchron/controller/dtos.py +++ b/src/warchron/controller/dtos.py @@ -132,3 +132,8 @@ class WarParticipantScoreDTO: tokens: int rank_icon: QIcon | None = None objective_icons: Dict[str, QIcon] = field(default_factory=dict) + + +@dataclass +class TieDialogData: + title: str diff --git a/src/warchron/controller/presenter.py b/src/warchron/controller/presenter.py new file mode 100644 index 0000000..665028a --- /dev/null +++ b/src/warchron/controller/presenter.py @@ -0,0 +1,52 @@ +from warchron.constants import ContextType +from warchron.controller.dtos import TieDialogData +from warchron.model.tie_manager import TieContext +from warchron.model.war import War +from warchron.model.campaign import Campaign +from warchron.model.round import Round + + +class TiePresenter: + + @staticmethod + def build_dialog_data( + war: War, + ctx: TieContext, + campaign: Campaign | None = None, + round: Round | None = None, + ) -> TieDialogData: + if ctx.context_type == ContextType.WAR: + if ctx.objective_id: + obj = war.objectives[ctx.objective_id] + return TieDialogData(f"War objective tie — {obj.name}") + return TieDialogData("War tie") + if ctx.context_type == ContextType.CAMPAIGN: + if ctx.objective_id: + obj = war.objectives[ctx.objective_id] + return TieDialogData(f"Campaign objective tie — {obj.name}") + return TieDialogData("Campaign tie") + if ctx.context_type == ContextType.BATTLE: + if campaign: + sector = campaign.sectors[ctx.context_id] + return TieDialogData(f"Battle tie — {sector.name}") + return TieDialogData("Battle tie") + if ctx.context_type == ContextType.CHOICE: + if ctx.sector_id and campaign and round: + sector = campaign.sectors[ctx.sector_id] + kind = TiePresenter._choice_kind(round, ctx) + return TieDialogData(f"Choice tie — {sector.name} ({kind})") + return TieDialogData("Choice tie") + return TieDialogData("Tie") + + @staticmethod + def _choice_kind(round: Round, ctx: TieContext) -> str: + for pid in ctx.participants: + camp_pid = round.campaign.war_to_campaign_part_id(pid) + choice = round.choices.get(camp_pid) + if not choice: + continue + if choice.priority_sector_id == ctx.sector_id: + return "priority" + if choice.secondary_sector_id == ctx.sector_id: + return "secondary" + return "choice" diff --git a/src/warchron/controller/round_controller.py b/src/warchron/controller/round_controller.py index 3b7f5e6..79a9729 100644 --- a/src/warchron/controller/round_controller.py +++ b/src/warchron/controller/round_controller.py @@ -28,6 +28,7 @@ from warchron.controller.closure_workflow import ( RoundClosureWorkflow, RoundPairingWorkflow, ) +from warchron.controller.presenter import TiePresenter from warchron.view.choice_dialog import ChoiceDialog from warchron.view.battle_dialog import BattleDialog from warchron.view.tie_dialog import TieDialog @@ -243,13 +244,26 @@ class RoundController: for pid in ctx.participants ] counters = [war.get_influence_tokens(pid) for pid in ctx.participants] - # TODO display sector name for BATTLE or CHOICE + if ctx.context_type == ContextType.BATTLE: + # context_id = battle.sector_id + campaign = war.get_campaign_by_sector(ctx.context_id) + if campaign: + round = campaign.get_round_by_battle(ctx.context_id) + if ctx.context_type == ContextType.CHOICE: + # context_id = round.id + campaign = war.get_campaign_by_round(ctx.context_id) + if campaign: + round = war.get_round(ctx.context_id) + data = TiePresenter.build_dialog_data( + war, ctx, round=round, campaign=campaign + ) dialog = TieDialog( parent=self.app.view, players=players, counters=counters, context_type=ctx.context_type, context_id=ctx.context_id, + context_name=data.title, ) if not dialog.exec(): TieResolver.cancel_tie_break(war, ctx) diff --git a/src/warchron/controller/war_controller.py b/src/warchron/controller/war_controller.py index 2c000e9..8ebd690 100644 --- a/src/warchron/controller/war_controller.py +++ b/src/warchron/controller/war_controller.py @@ -28,6 +28,7 @@ from warchron.model.tie_manager import TieContext, TieResolver from warchron.model.score_service import ScoreService from warchron.controller.closure_workflow import WarClosureWorkflow from warchron.controller.ranking_icon import RankingIcon +from warchron.controller.presenter import TiePresenter from warchron.view.war_dialog import WarDialog from warchron.view.objective_dialog import ObjectiveDialog from warchron.view.war_participant_dialog import WarParticipantDialog @@ -170,24 +171,18 @@ class WarController: for pid in active ] counters = [war.get_influence_tokens(pid) for pid in active] + data = TiePresenter.build_dialog_data( + war, + ctx, + ) dialog = TieDialog( parent=self.app.view, players=players, counters=counters, context_type=ctx.context_type, context_id=ctx.context_id, - context_name=None, + context_name=data.title, ) - if ctx.objective_id: - objective = war.objectives[ctx.objective_id] - dialog = TieDialog( - parent=self.app.view, - players=players, - counters=counters, - context_type=ctx.context_type, - context_id=ctx.context_id, - context_name=f"Objective tie: {objective.name}", - ) if not dialog.exec(): TieResolver.cancel_tie_break(war, ctx) raise AbortedOperation("Tie resolution cancelled") diff --git a/src/warchron/model/score_service.py b/src/warchron/model/score_service.py index 40b0891..58fc07e 100644 --- a/src/warchron/model/score_service.py +++ b/src/warchron/model/score_service.py @@ -4,7 +4,9 @@ from collections import defaultdict from warchron.constants import ContextType from warchron.model.war import War +from warchron.model.campaign import Campaign from warchron.model.battle import Battle +from warchron.model.exception import DomainError @dataclass(slots=True) @@ -26,14 +28,17 @@ class ScoreService: 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() + campaign: Campaign | None = war.get_campaign(context_id) + if campaign: + 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) + if not campaign: + raise DomainError(f"No campaign found for secor {battle.sector_id}") rnd = campaign.get_round_by_battle(context_id) if rnd and rnd.is_over: yield battle diff --git a/src/warchron/model/war.py b/src/warchron/model/war.py index d7c0f28..398d742 100644 --- a/src/warchron/model/war.py +++ b/src/warchron/model/war.py @@ -10,7 +10,11 @@ from warchron.model.war_event import ( InfluenceSpent, TieResolved, ) -from warchron.model.exception import ForbiddenOperation, RequiresConfirmation +from warchron.model.exception import ( + ForbiddenOperation, + RequiresConfirmation, + DomainError, +) from warchron.model.war_participant import WarParticipant from warchron.model.objective import Objective from warchron.model.campaign_participant import CampaignParticipant @@ -80,6 +84,10 @@ class War: if ev.context_type == ContextType.BATTLE: battle = self.get_battle(ev.context_id) campaign = self.get_campaign_by_sector(battle.sector_id) + if not campaign: + raise DomainError( + f"No campaign found for sector {battle.sector_id}" + ) round = campaign.get_round_by_battle(ev.context_id) round.is_over = False elif ev.context_type == ContextType.CAMPAIGN: @@ -313,12 +321,12 @@ class War: return None # TODO replace multiloops by internal has_* method - def get_campaign_by_sector(self, sector_id: str) -> Campaign: + def get_campaign_by_sector(self, sector_id: str) -> Campaign | None: for camp in self.campaigns: for sect in camp.sectors.values(): if sect.id == sector_id: return camp - raise KeyError(f"Sector {sector_id} not found in any Campaign") + return None def get_campaign_by_campaign_participant( self, participant_id: str @@ -396,6 +404,8 @@ class War: description: str | None, ) -> None: camp = self.get_campaign_by_sector(sector_id) + if not camp: + raise DomainError(f"No campaign found for sector {sector_id}") camp.update_sector( sector_id, name=name, @@ -409,6 +419,8 @@ class War: def remove_sector(self, sector_id: str) -> None: camp = self.get_campaign_by_sector(sector_id) + if not camp: + raise DomainError(f"No campaign found for sector {sector_id}") camp.remove_sector(sector_id) # Campaign participant methods diff --git a/src/warchron/view/tie_dialog.py b/src/warchron/view/tie_dialog.py index fd08f34..344f7af 100644 --- a/src/warchron/view/tie_dialog.py +++ b/src/warchron/view/tie_dialog.py @@ -34,7 +34,7 @@ class TieDialog(QDialog): self.ui: Ui_tieDialog = Ui_tieDialog() self.ui.setupUi(self) # type: ignore self.setWindowIcon(Icons.get(IconName.WARCHRONICO)) - self.ui.tieContext.setText(self._get_context_title(context_type, context_name)) + self.ui.tieContext.setText(context_name) grid = self.ui.playersGridLayout icon_path = (RESOURCES_DIR / Icons._paths[IconName.TOKENS]).as_posix() token_html = ( @@ -71,17 +71,3 @@ class TieDialog(QDialog): def get_bids(self) -> Dict[str, bool]: return {pid: checkbox.isChecked() for pid, checkbox in self._checkboxes.items()} - - @staticmethod - def _get_context_title( - context_type: ContextType, context_name: str | None = None - ) -> str: - if context_name: - return f"{context_name} tie" - titles = { - ContextType.BATTLE: "Battle tie", - ContextType.CAMPAIGN: "Campaign tie", - ContextType.WAR: "War tie", - ContextType.CHOICE: "Choice tie", - } - return titles.get(context_type, "Tie") From 4396b15c3a0f029be940bc25614b35a3724c4da0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20R=C3=A9aux?= Date: Thu, 19 Mar 2026 09:02:22 +0100 Subject: [PATCH 03/10] display pairing results in choice table --- src/warchron/constants.py | 23 ++++ src/warchron/controller/dtos.py | 5 +- src/warchron/controller/round_controller.py | 35 ++++-- src/warchron/model/pairing.py | 117 ++++++++++++++++++- src/warchron/model/round.py | 12 ++ src/warchron/model/tie_manager.py | 3 + src/warchron/view/resources/cross-script.png | Bin 0 -> 663 bytes src/warchron/view/resources/map.png | Bin 0 -> 1550 bytes src/warchron/view/view.py | 11 ++ 9 files changed, 194 insertions(+), 12 deletions(-) create mode 100644 src/warchron/view/resources/cross-script.png create mode 100644 src/warchron/view/resources/map.png diff --git a/src/warchron/constants.py b/src/warchron/constants.py index 5e2d857..951f91d 100644 --- a/src/warchron/constants.py +++ b/src/warchron/constants.py @@ -71,6 +71,9 @@ class IconName(StrEnum): NP3RDDRAW = auto() NP3RDBREAK = auto() NP3RDTIEDRAW = auto() + ALLOCATED = auto() + ALLOCATEDTOKEN = auto() + FALLBACK = auto() VP_RANK_TO_ICON = { @@ -125,6 +128,8 @@ class Icons: IconName.NP2ND: "medal-silver.png", IconName.NP3RD: "medal-bronze.png", IconName.WARCHRONBACK: "warchron_background.png", + IconName.ALLOCATED: "map.png", + IconName.FALLBACK: "cross-script.png", } @classmethod @@ -255,6 +260,11 @@ class Icons: cls.get_pixmap(IconName.DRAW), cls.get_pixmap(IconName.TOKEN), ) + elif name == IconName.ALLOCATEDTOKEN: + pix = cls._compose( + cls.get_pixmap(IconName.ALLOCATED), + cls.get_pixmap(IconName.TOKEN), + ) else: path = RESOURCES_DIR / cls._paths[name] pix = QPixmap(path.as_posix()) @@ -313,3 +323,16 @@ class ContextType(StrEnum): class ScoreKind(Enum): VP = auto() NP = auto() + + +class ChoiceStatus(StrEnum): + NONE = auto() + TOKEN = auto() + ALLOCATED = auto() + ALLOCATEDTOKEN = auto() + + +class AllocationType(Enum): + PRIORITY = auto() + SECONDARY = auto() + FALLBACK = auto() diff --git a/src/warchron/controller/dtos.py b/src/warchron/controller/dtos.py index 7882d08..97a08e8 100644 --- a/src/warchron/controller/dtos.py +++ b/src/warchron/controller/dtos.py @@ -88,6 +88,9 @@ class ChoiceDTO: priority_sector: str | None secondary_sector: str | None comment: str | None + priority_icon: QIcon | None = None + secondary_icon: QIcon | None = None + fallback_icon: QIcon | None = None @dataclass(frozen=True, slots=True) @@ -103,8 +106,6 @@ class BattleDTO: state_icon: QIcon | None player1_icon: QIcon | None player2_icon: QIcon | None - player1_tooltip: str | None = None - player2_tooltip: str | None = None @dataclass(frozen=True, slots=True) diff --git a/src/warchron/controller/round_controller.py b/src/warchron/controller/round_controller.py index 79a9729..ec3ad78 100644 --- a/src/warchron/controller/round_controller.py +++ b/src/warchron/controller/round_controller.py @@ -4,7 +4,14 @@ from PyQt6.QtWidgets import QDialog from PyQt6.QtWidgets import QMessageBox from PyQt6.QtGui import QIcon -from warchron.constants import ItemType, RefreshScope, Icons, IconName, ContextType +from warchron.constants import ( + ItemType, + RefreshScope, + Icons, + IconName, + ContextType, + ChoiceStatus, +) from warchron.model.exception import ( AbortedOperation, DomainError, @@ -12,6 +19,7 @@ from warchron.model.exception import ( ) from warchron.model.tie_manager import TieResolver, TieContext from warchron.model.result_checker import ResultChecker +from warchron.model.pairing import Pairing from warchron.model.round import Round from warchron.model.war import War @@ -62,6 +70,20 @@ class RoundController: if choice.secondary_sector_id is not None else "" ) + priority_icon = None + secondary_icon = None + fallback_icon = None + alloc = Pairing.get_round_allocation( + war, + rnd, + part.id, + ) + if alloc.priority != ChoiceStatus.NONE: + priority_icon = QIcon(Icons.get_pixmap(IconName[alloc.priority.name])) + if alloc.secondary != ChoiceStatus.NONE: + secondary_icon = QIcon(Icons.get_pixmap(IconName[alloc.secondary.name])) + if alloc.fallback: + fallback_icon = QIcon(Icons.get_pixmap(IconName.FALLBACK)) choices_for_display.append( ChoiceDTO( id=choice.participant_id, @@ -71,9 +93,11 @@ class RoundController: priority_sector=priority_name, secondary_sector=secondary_name, comment=choice.comment, + priority_icon=priority_icon, + secondary_icon=secondary_icon, + fallback_icon=fallback_icon, ) ) - # TODO display allocated sectors and used token self.app.view.display_round_choices(choices_for_display) battles_for_display: List[BattleDTO] = [] for sect in sectors: @@ -111,8 +135,7 @@ class RoundController: winner_name = "" p1_icon = None p2_icon = None - p1_tooltip = None - p2_tooltip = None + # TODO use uniform draw/tie icon logic with choice, war, campaign... if battle.is_draw(): p1_icon = Icons.get(IconName.DRAW) p2_icon = Icons.get(IconName.DRAW) @@ -131,10 +154,8 @@ class RoundController: pixmap = Icons.get_pixmap(IconName.TIEBREAK_TOKEN) if effective_winner == p1_war: p1_icon = QIcon(pixmap) - p1_tooltip = "Won by tie-break" else: p2_icon = QIcon(pixmap) - p2_tooltip = "Won by tie-break" elif battle.winner_id: if battle.winner_id == battle.player_1_id: p1_icon = Icons.get(IconName.WIN) @@ -153,8 +174,6 @@ class RoundController: state_icon=state_icon, player1_icon=p1_icon, player2_icon=p2_icon, - player1_tooltip=p1_tooltip, - player2_tooltip=p2_tooltip, ) ) self.app.view.display_round_battles(battles_for_display) diff --git a/src/warchron/model/pairing.py b/src/warchron/model/pairing.py index a6dcabd..d2c8f88 100644 --- a/src/warchron/model/pairing.py +++ b/src/warchron/model/pairing.py @@ -1,10 +1,11 @@ from __future__ import annotations from typing import Dict, List, Callable, Tuple +from dataclasses import dataclass from uuid import uuid4 import random -from warchron.constants import ContextType, ScoreKind +from warchron.constants import ContextType, ScoreKind, ChoiceStatus, AllocationType from warchron.model.exception import ( DomainError, ForbiddenOperation, @@ -15,7 +16,7 @@ from warchron.model.round import Round from warchron.model.battle import Battle from warchron.model.score_service import ScoreService from warchron.model.tie_manager import TieResolver, TieContext -from warchron.model.war_event import TieResolved +from warchron.model.war_event import TieResolved, InfluenceSpent from warchron.model.score_service import ParticipantScore ResolveTiesCallback = Callable[ @@ -24,6 +25,13 @@ ResolveTiesCallback = Callable[ ] +@dataclass(frozen=True, slots=True) +class AllocationResult: + priority: ChoiceStatus + secondary: ChoiceStatus + fallback: bool + + class Pairing: @staticmethod @@ -218,6 +226,10 @@ class Pairing: ) or len(active) <= places ): + print( + f"Natural or acceptable draw for sector {sector_id} with participants:\n", + context.participants, + ) war.events.append( TieResolved( None, @@ -234,6 +246,10 @@ class Pairing: bids = bids_map[current_context.key()] # confirmed draw if current bids are 0 if bids is not None and not any(bids.values()): + print( + f"Confirmed draw for sector {sector_id} with participants:\n", + context.participants, + ) war.events.append( TieResolved( None, @@ -278,3 +294,100 @@ class Pairing: remaining.remove(pid) continue raise DomainError(f"Ambiguous fallback for participant {pid}") + + @staticmethod + def get_allocation_kind( + war: War, + round_id: str, + participant_id: str, + sector_id: str, + ) -> AllocationType: + round = war.get_round(round_id) + choice = round.choices.get(participant_id) + if not choice: + raise DomainError(f"No choice found for participant {participant_id}") + if choice.priority_sector_id == sector_id: + return AllocationType.PRIORITY + if choice.secondary_sector_id == sector_id: + return AllocationType.SECONDARY + return AllocationType.FALLBACK + + @staticmethod + def participant_spent_token( + war: War, + round_id: str, + sector_id: str | None, + war_participant_id: str, + ) -> bool: + if sector_id is None: + return False + for ev in war.events: + if not isinstance(ev, InfluenceSpent): + continue + if ev.context_type != ContextType.CHOICE: + continue + if ev.context_id != round_id: + continue + if ev.sector_id != sector_id: + continue + if ev.participant_id == war_participant_id: + return True + return False + + @staticmethod + def get_round_allocation( + war: War, + round: Round, + campaign_participant_id: str, + ) -> AllocationResult: + choice = round.choices[campaign_participant_id] + campaign = war.get_campaign_by_round(round.id) + if campaign is None: + raise DomainError(f"No campaign found for round {round.id}") + war_pid = campaign.campaign_to_war_part_id(campaign_participant_id) + + token_priority = Pairing.participant_spent_token( + war, + round.id, + choice.priority_sector_id, + war_pid, + ) + token_secondary = Pairing.participant_spent_token( + war, + round.id, + choice.secondary_sector_id, + war_pid, + ) + battle = round.get_battle_for_participant(campaign_participant_id) + allocation = AllocationType.FALLBACK + if battle: + allocation = Pairing.get_allocation_kind( + war, + round.id, + campaign_participant_id, + battle.sector_id, + ) + priority_status = ChoiceStatus.NONE + secondary_status = ChoiceStatus.NONE + fallback = allocation == AllocationType.FALLBACK + if allocation == AllocationType.PRIORITY: + priority_status = ( + ChoiceStatus.ALLOCATEDTOKEN + if token_priority + else ChoiceStatus.ALLOCATED + ) + elif token_priority: + priority_status = ChoiceStatus.TOKEN + if allocation == AllocationType.SECONDARY: + secondary_status = ( + ChoiceStatus.ALLOCATEDTOKEN + if token_secondary + else ChoiceStatus.ALLOCATED + ) + elif token_secondary: + secondary_status = ChoiceStatus.TOKEN + return AllocationResult( + priority=priority_status, + secondary=secondary_status, + fallback=fallback, + ) diff --git a/src/warchron/model/round.py b/src/warchron/model/round.py index 05b7935..a7b4363 100644 --- a/src/warchron/model/round.py +++ b/src/warchron/model/round.py @@ -132,6 +132,18 @@ class Round: def get_battle(self, sector_id: str) -> Battle | None: return self.battles.get(sector_id) + def get_battle_for_participant( + self, + campaign_participant_id: str, + ) -> Battle | None: + for battle in self.battles.values(): + if ( + battle.player_1_id == campaign_participant_id + or battle.player_2_id == campaign_participant_id + ): + return battle + return None + def has_battle_with_sector(self, sector_id: str) -> bool: return any(bat.sector_id == sector_id for bat in self.battles.values()) diff --git a/src/warchron/model/tie_manager.py b/src/warchron/model/tie_manager.py index 4694f57..0d416eb 100644 --- a/src/warchron/model/tie_manager.py +++ b/src/warchron/model/tie_manager.py @@ -327,6 +327,7 @@ class TieResolver: context_id=context.context_id, tie_id=tie_id, objective_id=context.objective_id, + sector_id=context.sector_id, ) ) @@ -418,6 +419,7 @@ class TieResolver: tie_id=tie_id, score_value=context.score_value, objective_id=context.objective_id, + sector_id=context.sector_id, ) ) return @@ -432,6 +434,7 @@ class TieResolver: tie_id=tie_id, score_value=context.score_value, objective_id=context.objective_id, + sector_id=context.sector_id, ) ) return diff --git a/src/warchron/view/resources/cross-script.png b/src/warchron/view/resources/cross-script.png new file mode 100644 index 0000000000000000000000000000000000000000..70b59dc9debb268312321d0b10acd2f59f93c346 GIT binary patch literal 663 zcmV;I0%-k-P)I^#n zUT|vg3=v?B0yLT{7Bl;EImu8}t2{IFUY0^Yb4O?iifc1J27k5V$$FKm@>}!F+yzL@`vW zRF*VNJ1BUmxNA2jaBHx-y)9{6-sqR4J-RFhm+JLuDi%wqw!QGjvIb3Fv&Pmo>Rc?( zczs_weYnTx>#5Z1HQ6v8t~8q;LV-X8w(0i&=Ac6W_Oaer?8E_4l-3kweZw%GBjl%W zJo!ykr=os;7W!?Ar@KjDdwFLKqKikv;jCtwf6KD`3FjvYY;6y7bV}P3ZSB zJB)6@!(PMK7Fef^f*|aLVR(S(3c~&U7I~t|%fPB$aBdbph%*AgKBB%P(XIu?UUvxM xeB1;4p%%}~;yUBAdr;GM952YYj`?xD6H;0Z>@i-?pAIt}ee<*c6R&nAy^#mVN4xih^r3lC$J9C%(UZ2nB`}}21 zw&mj?31bsPBGC|2CY~dV!=tY_UiePzdPxey`@GS{=dugHBB014@(J-Uc6k8UfZKo5lX z=%ufmvYE31jO8dGO%BSOpaujqkQ`E}wOVZ|padaM0V)-cQU+-el@b9#p!bmq);QM! zBnKyYZ3!#A)XnpLM4<=0k?ay;Sh@RR}(R4xR7j1O4Jmpf_1U+7uzuuYlyBg6qLV|!V>f_R{xrR4# z5safqp5?48>+NfXY&XlZ0XORhuv`@|hh|)CFc7Vi;8oO5ilY}%E`noyKu^jLdVux9 zLcuhOL4?w%Q>m0{6el1EMGZPEO$CFvF3pf;P)U1W;n5UJ^EyfP+7sV8qv! zf3$nnrmN{h_sImcvY@r5Su!iUtK}5-&HV{+_Vyp+(W;flmUC|8hs^TF>*k*yeR*%q zFU>oX=FJX{AYgH&T3nowoLA<&pxfR)wamK7(v8Ls-clRWFt_CXh7oZRW}*GVqlx0H zJL0l#**?Fzxn}6tDYn#W#||G_<9(V~Avxb(1V!eT9xsXyHi=&*i{aJ8+b(Z1kzMC) zh~!Q5-yBzZSu(z5qwc@~;MuZ~nI-GuuzDg1oM%ej7hbyI!EP0vx~i2Pw&&WaX;1zv zGK#zHC8zQA*_KH7_n-GUp5>ikcS^pV9Km1Qn^K+<-v1ctypdUZcvnn^TvjmmpVFCs zoNVr>VlEc68|w)9c2n#Q&*&45ap8X@Uwogsx1b{9gDG(FjVtb9>(A}q3NDeQER?Le z=Nr=+dvt8(EA9{ Date: Thu, 19 Mar 2026 11:25:40 +0100 Subject: [PATCH 04/10] raise exception on choice/battle constrained update/delete --- src/warchron/model/pairing.py | 26 +++++---------- src/warchron/model/round.py | 63 ++++++++++++++++++++++++++++++++--- 2 files changed, 67 insertions(+), 22 deletions(-) diff --git a/src/warchron/model/pairing.py b/src/warchron/model/pairing.py index d2c8f88..fed5234 100644 --- a/src/warchron/model/pairing.py +++ b/src/warchron/model/pairing.py @@ -47,15 +47,6 @@ class Pairing: 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 ForbiddenOperation( - f"Missing priority choice for participant {pid}" - ) - if choice is not None and not choice.secondary_sector_id: - raise ForbiddenOperation( - f"Missing secondary choice for participant {pid}" - ) def cleanup() -> None: for bat in round.battles.values(): @@ -74,6 +65,15 @@ class Pairing: "Do you want to continue?", action=cleanup, ) + for pid, choice in round.choices.items(): + if choice is not None and not choice.priority_sector_id: + raise ForbiddenOperation( + f"Missing priority choice for participant {pid}" + ) + if choice is not None and not choice.secondary_sector_id: + raise ForbiddenOperation( + f"Missing secondary choice for participant {pid}" + ) @staticmethod def assign_battles_to_participants( @@ -226,10 +226,6 @@ class Pairing: ) or len(active) <= places ): - print( - f"Natural or acceptable draw for sector {sector_id} with participants:\n", - context.participants, - ) war.events.append( TieResolved( None, @@ -246,10 +242,6 @@ class Pairing: bids = bids_map[current_context.key()] # confirmed draw if current bids are 0 if bids is not None and not any(bids.values()): - print( - f"Confirmed draw for sector {sector_id} with participants:\n", - context.participants, - ) war.events.append( TieResolved( None, diff --git a/src/warchron/model/round.py b/src/warchron/model/round.py index a7b4363..f0db6e2 100644 --- a/src/warchron/model/round.py +++ b/src/warchron/model/round.py @@ -5,7 +5,11 @@ from typing import Any, Dict, List, TYPE_CHECKING if TYPE_CHECKING: from warchron.model.campaign import Campaign from warchron.model.war import War -from warchron.model.exception import ForbiddenOperation +from warchron.model.exception import ( + ForbiddenOperation, + DomainError, + RequiresConfirmation, +) from warchron.model.choice import Choice from warchron.model.battle import Battle @@ -95,7 +99,9 @@ class Round: if self.is_over: # TODO catch me if you can raise ForbiddenOperation("Can't update choice in a closed round.") - # TODO prevent if battles already assigned + if self.has_battle_with_participant(participant_id): + # TODO catch me if you can (inner) + raise ForbiddenOperation("Can't update choice already assigned to battle.") choice = self.get_choice(participant_id) if choice: choice.set_priority(priority_sector_id) @@ -120,7 +126,9 @@ class Round: if self.is_over: # TODO catch me if you can (inner) raise ForbiddenOperation("Can't remove choice in a closed round.") - # TODO prevent if battles already assigned + if self.has_battle_with_participant(participant_id): + # TODO catch me if you can (inner) + raise ForbiddenOperation("Can't remove choice already assigned to battle.") self.war.revert_choice_ties( self.id, participants=[self.campaign.campaign_to_war_part_id(participant_id)], @@ -185,12 +193,16 @@ class Round: victory_condition: str | None, comment: str | None, ) -> None: + from warchron.model.pairing import Pairing + if self.is_over: # TODO catch me if you can raise ForbiddenOperation("Can't update battle in a closed round.") bat = self.get_battle(sector_id) - # TODO require confirmation if there was choice tie to clear it - if bat: + if not bat: + raise DomainError(f"No battle found for sector {sector_id}") + + def apply_update() -> None: bat.set_player_1(player_1_id) bat.set_player_2(player_2_id) bat.set_winner(winner_id) @@ -198,6 +210,47 @@ class Round: bat.set_victory_condition(victory_condition) bat.set_comment(comment) + if bat.player_1_id == player_1_id and bat.player_2_id == player_2_id: + apply_update() + return + affected_choices: List[Choice] = [] + affected_players: List[str | None] = list( + { + player_1_id, + player_2_id, + bat.player_1_id, + bat.player_2_id, + } + - {None} + ) + for choice in self.choices.values(): + for player in affected_players: + if ( + player + and self.has_choice_with_participant(player) + and Pairing.participant_spent_token( + self.war, + self.id, + sector_id, + self.campaign.campaign_to_war_part_id(player), + ) + ): + affected_choices.append(choice) + if not affected_choices: + apply_update() + return + + def cleanup_and_update() -> None: + self.clear_sector_references(sector_id) + apply_update() + + raise RequiresConfirmation( + "Changing the player(s) of this sector will affect choices.\n" + "Choices will be cleared and their tokens and tie-breaks will be deleted.\n" + "Do you want to continue?", + action=cleanup_and_update, + ) + def clear_participant_references(self, participant_id: str) -> None: for battle in self.battles.values(): trigger_revert_ties = False From 0081e52e9a622883c8a027d88892bfea706461fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20R=C3=A9aux?= Date: Thu, 19 Mar 2026 12:03:49 +0100 Subject: [PATCH 05/10] fix uncaucht choice/battle exceptions --- src/warchron/controller/app_controller.py | 12 +- src/warchron/controller/round_controller.py | 240 +++++++++++--------- src/warchron/model/campaign.py | 6 +- src/warchron/model/round.py | 18 +- src/warchron/view/view.py | 6 + 5 files changed, 156 insertions(+), 126 deletions(-) diff --git a/src/warchron/controller/app_controller.py b/src/warchron/controller/app_controller.py index 1548839..ac98aad 100644 --- a/src/warchron/controller/app_controller.py +++ b/src/warchron/controller/app_controller.py @@ -357,12 +357,20 @@ class AppController: except RequiresConfirmation as e: reply = QMessageBox.question( self.view, - "Confirm deletion", + "Confirm update", str(e), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if reply == QMessageBox.StandardButton.Yes: - e.action() + try: + e.action() + except DomainError as inner: + QMessageBox.warning( + self.view, + "Update forbidden", + str(inner), + ) + return else: return self.is_dirty = True diff --git a/src/warchron/controller/round_controller.py b/src/warchron/controller/round_controller.py index ec3ad78..3aa4b36 100644 --- a/src/warchron/controller/round_controller.py +++ b/src/warchron/controller/round_controller.py @@ -47,6 +47,7 @@ class RoundController: self.app = app def _fill_round_details(self, round_id: str) -> None: + # self.app.view.clear_round_page() rnd = self.app.model.get_round(round_id) camp = self.app.model.get_campaign_by_round(round_id) war = self.app.model.get_war_by_round(round_id) @@ -57,125 +58,144 @@ class RoundController: for part in participants: choice = rnd.get_choice(part.id) if not choice: - choice = self.app.model.create_choice( - round_id=rnd.id, participant_id=part.id + try: + choice = self.app.model.create_choice( + round_id=rnd.id, participant_id=part.id + ) + except DomainError as e: + QMessageBox.warning( + self.app.view, + "Create forbidden", + str(e), + ) + else: + priority_name = ( + camp.get_sector_name(choice.priority_sector_id) + if choice.priority_sector_id is not None + else "" ) - priority_name = ( - camp.get_sector_name(choice.priority_sector_id) - if choice.priority_sector_id is not None - else "" - ) - secondary_name = ( - camp.get_sector_name(choice.secondary_sector_id) - if choice.secondary_sector_id is not None - else "" - ) - priority_icon = None - secondary_icon = None - fallback_icon = None - alloc = Pairing.get_round_allocation( - war, - rnd, - part.id, - ) - if alloc.priority != ChoiceStatus.NONE: - priority_icon = QIcon(Icons.get_pixmap(IconName[alloc.priority.name])) - if alloc.secondary != ChoiceStatus.NONE: - secondary_icon = QIcon(Icons.get_pixmap(IconName[alloc.secondary.name])) - if alloc.fallback: - fallback_icon = QIcon(Icons.get_pixmap(IconName.FALLBACK)) - choices_for_display.append( - ChoiceDTO( - id=choice.participant_id, - participant_name=self.app.model.get_participant_name( - part.war_participant_id - ), - priority_sector=priority_name, - secondary_sector=secondary_name, - comment=choice.comment, - priority_icon=priority_icon, - secondary_icon=secondary_icon, - fallback_icon=fallback_icon, + secondary_name = ( + camp.get_sector_name(choice.secondary_sector_id) + if choice.secondary_sector_id is not None + else "" + ) + priority_icon = None + secondary_icon = None + fallback_icon = None + alloc = Pairing.get_round_allocation( + war, + rnd, + part.id, + ) + if alloc.priority != ChoiceStatus.NONE: + priority_icon = QIcon( + Icons.get_pixmap(IconName[alloc.priority.name]) + ) + if alloc.secondary != ChoiceStatus.NONE: + secondary_icon = QIcon( + Icons.get_pixmap(IconName[alloc.secondary.name]) + ) + if alloc.fallback: + fallback_icon = QIcon(Icons.get_pixmap(IconName.FALLBACK)) + choices_for_display.append( + ChoiceDTO( + id=choice.participant_id, + participant_name=self.app.model.get_participant_name( + part.war_participant_id + ), + priority_sector=priority_name, + secondary_sector=secondary_name, + comment=choice.comment, + priority_icon=priority_icon, + secondary_icon=secondary_icon, + fallback_icon=fallback_icon, + ) ) - ) self.app.view.display_round_choices(choices_for_display) battles_for_display: List[BattleDTO] = [] for sect in sectors: battle = rnd.get_battle(sect.id) - if not battle: - battle = self.app.model.create_battle( - round_id=rnd.id, sector_id=sect.id - ) - state_icon = Icons.get(IconName.ONGOING) - if battle.is_finished(): - state_icon = Icons.get(IconName.DONE) - if battle.player_1_id: - camp_part = camp.participants[battle.player_1_id] - player_1_name = self.app.model.get_participant_name( - camp_part.war_participant_id - ) - p1_id = battle.player_1_id - else: - player_1_name = "" - if battle.player_2_id: - camp_part = camp.participants[battle.player_2_id] - player_2_name = self.app.model.get_participant_name( - camp_part.war_participant_id - ) - p2_id = battle.player_2_id - else: - player_2_name = "" - if battle.winner_id: - camp_part = camp.participants[battle.winner_id] - winner_name = self.app.model.get_participant_name( - camp_part.war_participant_id - ) - else: - winner_name = "" - p1_icon = None - p2_icon = None - # TODO use uniform draw/tie icon logic with choice, war, campaign... - if battle.is_draw(): - p1_icon = Icons.get(IconName.DRAW) - p2_icon = Icons.get(IconName.DRAW) - context = TieContext( - ContextType.BATTLE, - battle.sector_id, - [p1_id, p2_id], - ) - if TieResolver.was_tie_broken_by_tokens(war, context): - effective_winner = ResultChecker.get_effective_winner_id( - war, ContextType.BATTLE, battle.sector_id, None + try: + battle = self.app.model.create_battle( + round_id=rnd.id, sector_id=sect.id + ) + except DomainError as e: + QMessageBox.warning( + self.app.view, + "Create forbidden", + str(e), + ) + else: + state_icon = Icons.get(IconName.ONGOING) + if battle.is_finished(): + state_icon = Icons.get(IconName.DONE) + if battle.player_1_id: + camp_part = camp.participants[battle.player_1_id] + player_1_name = self.app.model.get_participant_name( + camp_part.war_participant_id + ) + p1_id = battle.player_1_id + else: + player_1_name = "" + if battle.player_2_id: + camp_part = camp.participants[battle.player_2_id] + player_2_name = self.app.model.get_participant_name( + camp_part.war_participant_id + ) + p2_id = battle.player_2_id + else: + player_2_name = "" + if battle.winner_id: + camp_part = camp.participants[battle.winner_id] + winner_name = self.app.model.get_participant_name( + camp_part.war_participant_id + ) + else: + winner_name = "" + p1_icon = None + p2_icon = None + # TODO use uniform draw/tie icon logic with choice, war, campaign... + if battle.is_draw(): + p1_icon = Icons.get(IconName.DRAW) + p2_icon = Icons.get(IconName.DRAW) + context = TieContext( + ContextType.BATTLE, + battle.sector_id, + [p1_id, p2_id], + ) + if TieResolver.was_tie_broken_by_tokens(war, context): + effective_winner = ResultChecker.get_effective_winner_id( + war, ContextType.BATTLE, battle.sector_id, None + ) + p1_war = None + if battle.player_1_id is not None: + p1_war = camp.campaign_to_war_part_id(battle.player_1_id) + pixmap = Icons.get_pixmap(IconName.TIEBREAK_TOKEN) + if effective_winner == p1_war: + p1_icon = QIcon(pixmap) + else: + p2_icon = QIcon(pixmap) + elif battle.winner_id: + if battle.winner_id == battle.player_1_id: + p1_icon = Icons.get(IconName.WIN) + elif battle.winner_id == battle.player_2_id: + p2_icon = Icons.get(IconName.WIN) + battles_for_display.append( + BattleDTO( + id=battle.sector_id, + sector_name=camp.get_sector_name(battle.sector_id), + player_1=player_1_name, + player_2=player_2_name, + winner=winner_name, + score=battle.score, + victory_condition=battle.victory_condition, + comment=battle.comment, + state_icon=state_icon, + player1_icon=p1_icon, + player2_icon=p2_icon, ) - p1_war = None - if battle.player_1_id is not None: - p1_war = camp.campaign_to_war_part_id(battle.player_1_id) - pixmap = Icons.get_pixmap(IconName.TIEBREAK_TOKEN) - if effective_winner == p1_war: - p1_icon = QIcon(pixmap) - else: - p2_icon = QIcon(pixmap) - elif battle.winner_id: - if battle.winner_id == battle.player_1_id: - p1_icon = Icons.get(IconName.WIN) - elif battle.winner_id == battle.player_2_id: - p2_icon = Icons.get(IconName.WIN) - battles_for_display.append( - BattleDTO( - id=battle.sector_id, - sector_name=camp.get_sector_name(battle.sector_id), - player_1=player_1_name, - player_2=player_2_name, - winner=winner_name, - score=battle.score, - victory_condition=battle.victory_condition, - comment=battle.comment, - state_icon=state_icon, - player1_icon=p1_icon, - player2_icon=p2_icon, ) - ) self.app.view.display_round_battles(battles_for_display) self.app.view.endRoundBtn.setEnabled(not rnd.is_over) diff --git a/src/warchron/model/campaign.py b/src/warchron/model/campaign.py index 15fd545..ee2a136 100644 --- a/src/warchron/model/campaign.py +++ b/src/warchron/model/campaign.py @@ -144,8 +144,8 @@ class Campaign: def cleanup() -> None: for rnd in rounds_blocking: - rnd.clear_participant_references(participant_id) rnd.remove_choice(participant_id) + rnd.clear_participant_references(participant_id) del self.participants[participant_id] rounds_str = ", ".join( @@ -263,8 +263,8 @@ class Campaign: def cleanup_and_update() -> None: for rnd in affected_rounds: - rnd.clear_sector_references(sector_id) rnd.remove_battle(sector_id) + rnd.clear_sector_references(sector_id) apply_update() rounds_str = ", ".join( @@ -299,8 +299,8 @@ class Campaign: def cleanup() -> None: for rnd in rounds_blocking: - rnd.clear_sector_references(sector_id) rnd.remove_battle(sector_id) + rnd.clear_sector_references(sector_id) del self.sectors[sector_id] rounds_str = ", ".join( diff --git a/src/warchron/model/round.py b/src/warchron/model/round.py index f0db6e2..86b279d 100644 --- a/src/warchron/model/round.py +++ b/src/warchron/model/round.py @@ -78,7 +78,6 @@ class Round: def create_choice(self, participant_id: str) -> Choice: if self.is_over: - # TODO catch me if you can raise ForbiddenOperation("Can't create choice in a closed round.") if participant_id not in self.choices: choice = Choice( @@ -97,13 +96,16 @@ class Round: comment: str | None, ) -> None: if self.is_over: - # TODO catch me if you can raise ForbiddenOperation("Can't update choice in a closed round.") - if self.has_battle_with_participant(participant_id): - # TODO catch me if you can (inner) - raise ForbiddenOperation("Can't update choice already assigned to battle.") choice = self.get_choice(participant_id) if choice: + if self.has_battle_with_participant(participant_id) and ( + priority_sector_id != choice.priority_sector_id + or secondary_sector_id != choice.secondary_sector_id + ): + raise ForbiddenOperation( + "Can't update choice already assigned to battle." + ) choice.set_priority(priority_sector_id) choice.set_secondary(secondary_sector_id) choice.set_comment(comment) @@ -124,10 +126,8 @@ class Round: if participant_id not in self.choices: return if self.is_over: - # TODO catch me if you can (inner) raise ForbiddenOperation("Can't remove choice in a closed round.") if self.has_battle_with_participant(participant_id): - # TODO catch me if you can (inner) raise ForbiddenOperation("Can't remove choice already assigned to battle.") self.war.revert_choice_ties( self.id, @@ -176,7 +176,6 @@ class Round: def create_battle(self, sector_id: str) -> Battle: if self.is_over: - # TODO catch me if you can raise ForbiddenOperation("Can't create battle in a closed round.") if sector_id not in self.battles: battle = Battle(sector_id=sector_id, player_1_id=None, player_2_id=None) @@ -196,7 +195,6 @@ class Round: from warchron.model.pairing import Pairing if self.is_over: - # TODO catch me if you can raise ForbiddenOperation("Can't update battle in a closed round.") bat = self.get_battle(sector_id) if not bat: @@ -274,11 +272,9 @@ class Round: if sector_id not in self.battles: return if self.is_over: - # TODO catch me if you can raise ForbiddenOperation("Can't remove battle in a closed round.") bat = self.battles[sector_id] if bat and bat.is_finished(): - # TODO catch me if you can raise ForbiddenOperation("Can't remove finished battle.") self.war.revert_battle_ties(self.id, sector_id=sector_id) del self.battles[sector_id] diff --git a/src/warchron/view/view.py b/src/warchron/view/view.py index 80ac522..53fab4b 100644 --- a/src/warchron/view/view.py +++ b/src/warchron/view/view.py @@ -599,6 +599,12 @@ class View(QtWidgets.QMainWindow, Ui_MainWindow): def show_round_details(self, *, index: int | None) -> None: self.roundNb.setText(f"Round {index}") + def clear_round_page(self) -> None: + choices_table = self.choicesTable + choices_table.clearContents() + battles_table = self.battlesTable + battles_table.clearContents() + def display_round_choices(self, participants: List[ChoiceDTO]) -> None: table = self.choicesTable table.setSortingEnabled(False) From 3d0d7874e379d17400c58589786d90d9e0b6155d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20R=C3=A9aux?= Date: Thu, 19 Mar 2026 15:10:48 +0100 Subject: [PATCH 06/10] unifomise tiebreak icons + refacto presenter --- src/warchron/constants.py | 10 +- .../controller/campaign_controller.py | 10 +- src/warchron/controller/presenter.py | 134 +++++++++++++++++- src/warchron/controller/ranking_icon.py | 80 ----------- src/warchron/controller/round_controller.py | 41 +----- src/warchron/controller/war_controller.py | 10 +- src/warchron/model/pairing.py | 30 +--- src/warchron/model/round.py | 6 +- src/warchron/model/tie_manager.py | 23 +++ src/warchron/view/view.py | 6 - 10 files changed, 185 insertions(+), 165 deletions(-) delete mode 100644 src/warchron/controller/ranking_icon.py diff --git a/src/warchron/constants.py b/src/warchron/constants.py index 951f91d..495565d 100644 --- a/src/warchron/constants.py +++ b/src/warchron/constants.py @@ -49,7 +49,8 @@ class IconName(StrEnum): NP1ST = auto() NP2ND = auto() NP3RD = auto() - TIEBREAK_TOKEN = auto() + WINTOKEN = auto() + TIEBREAKTOKEN = auto() VP1STDRAW = auto() VP1STBREAK = auto() VP1STTIEDRAW = auto() @@ -143,11 +144,16 @@ class Icons: def get_pixmap(cls, name: IconName) -> QPixmap: if name in cls._pixmap_cache: return cls._pixmap_cache[name] - if name == IconName.TIEBREAK_TOKEN: + if name == IconName.TIEBREAKTOKEN: pix = cls._compose( cls.get_pixmap(IconName.TIEBREAK), cls.get_pixmap(IconName.TOKEN), ) + elif name == IconName.WINTOKEN: + pix = cls._compose( + cls.get_pixmap(IconName.WIN), + cls.get_pixmap(IconName.TOKEN), + ) elif name == IconName.VP1STDRAW: pix = cls._compose( cls.get_pixmap(IconName.VP1ST), diff --git a/src/warchron/controller/campaign_controller.py b/src/warchron/controller/campaign_controller.py index 42855c1..b688bfd 100644 --- a/src/warchron/controller/campaign_controller.py +++ b/src/warchron/controller/campaign_controller.py @@ -22,8 +22,8 @@ from warchron.model.sector import Sector from warchron.model.tie_manager import TieContext, TieResolver from warchron.model.score_service import ScoreService from warchron.controller.closure_workflow import CampaignClosureWorkflow -from warchron.controller.ranking_icon import RankingIcon -from warchron.controller.presenter import TiePresenter + +from warchron.controller.presenter import Presenter from warchron.view.campaign_dialog import CampaignDialog from warchron.view.campaign_participant_dialog import CampaignParticipantDialog from warchron.view.sector_dialog import SectorDialog @@ -58,11 +58,11 @@ class CampaignController: vp_icon_map: Dict[str, QIcon] = {} objective_icon_maps: Dict[str, Dict[str, QIcon]] = {} if camp.is_over: - vp_icon_map = RankingIcon.compute_icons( + vp_icon_map = Presenter.compute_ranking_icons( war, ContextType.CAMPAIGN, campaign_id, scores ) for obj in war.get_objectives_used_as_maj_or_min(): - objective_icon_maps[obj.id] = RankingIcon.compute_icons( + objective_icon_maps[obj.id] = Presenter.compute_ranking_icons( war, ContextType.CAMPAIGN, camp.id, @@ -177,7 +177,7 @@ class CampaignController: for pid in active ] counters = [war.get_influence_tokens(pid) for pid in active] - data = TiePresenter.build_dialog_data( + data = Presenter.build_dialog_data( war, ctx, campaign=war.get_campaign(ctx.context_id) ) dialog = TieDialog( diff --git a/src/warchron/controller/presenter.py b/src/warchron/controller/presenter.py index 665028a..a90be32 100644 --- a/src/warchron/controller/presenter.py +++ b/src/warchron/controller/presenter.py @@ -1,12 +1,27 @@ -from warchron.constants import ContextType +from typing import Dict + +from PyQt6.QtGui import QIcon + +from warchron.constants import ( + ContextType, + Icons, + IconName, + VP_RANK_TO_ICON, + NP_RANK_TO_ICON, + ScoreKind, +) + from warchron.controller.dtos import TieDialogData -from warchron.model.tie_manager import TieContext +from warchron.model.tie_manager import TieContext, TieResolver from warchron.model.war import War from warchron.model.campaign import Campaign from warchron.model.round import Round +from warchron.model.score_service import ParticipantScore +from warchron.model.result_checker import ResultChecker +from warchron.model.exception import DomainError -class TiePresenter: +class Presenter: @staticmethod def build_dialog_data( @@ -33,7 +48,7 @@ class TiePresenter: if ctx.context_type == ContextType.CHOICE: if ctx.sector_id and campaign and round: sector = campaign.sectors[ctx.sector_id] - kind = TiePresenter._choice_kind(round, ctx) + kind = Presenter._choice_kind(round, ctx) return TieDialogData(f"Choice tie — {sector.name} ({kind})") return TieDialogData("Choice tie") return TieDialogData("Tie") @@ -50,3 +65,114 @@ class TiePresenter: if choice.secondary_sector_id == ctx.sector_id: return "secondary" return "choice" + + @staticmethod + def compute_ranking_icons( + war: War, + context_type: ContextType, + context_id: str, + scores: Dict[str, ParticipantScore], + *, + objective_id: str | None = None, + ) -> Dict[str, QIcon]: + if objective_id is None: + + def value_getter(score: ParticipantScore) -> int: + return score.victory_points + + icon_ranking = VP_RANK_TO_ICON + score_kind = ScoreKind.VP + else: + + def value_getter(score: ParticipantScore) -> int: + return score.narrative_points.get(objective_id, 0) + + icon_ranking = NP_RANK_TO_ICON + score_kind = ScoreKind.NP + ranking = ResultChecker.get_effective_ranking( + war, + context_type, + context_id, + score_kind, + scores, + value_getter, + objective_id, + ) + icon_map: Dict[str, QIcon] = {} + for rank, group, token_map in ranking: + if objective_id and rank not in icon_ranking: + continue + base_icon = icon_ranking.get( + rank, IconName.VPNTH if objective_id is None else None + ) + if base_icon is None: + continue + value = value_getter(scores[group[0]]) + original_group_size = sum( + 1 for s in scores.values() if value_getter(s) == value + ) + 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 + + @staticmethod + def compute_battle_icons( + war: War, + round_id: str, + battle_id: str, + ) -> tuple[QIcon | None, QIcon | None]: + battle = war.get_battle(battle_id) + if not (battle.player_1_id and battle.player_2_id and battle.is_finished()): + return None, None + campaign = war.get_campaign_by_sector(battle.sector_id) + if not campaign: + raise DomainError("No campaign found for this battle") + base_winner = None + if battle.winner_id is not None: + base_winner = campaign.campaign_to_war_part_id(battle.winner_id) + winner_id = ResultChecker.get_effective_winner_id( + war, + ContextType.BATTLE, + battle.sector_id, + base_winner, + ) + + def compute_icon(player: str) -> QIcon | None: + base_icon: IconName | None = None + if winner_id is None: + base_icon = IconName.DRAW + elif campaign.war_to_campaign_part_id(winner_id) == player: + base_icon = IconName.WIN + elif battle.is_draw(): + base_icon = IconName.TIEBREAK + if base_icon is None: + return None + spent = TieResolver.participant_spent_token( + war, + ContextType.BATTLE, + battle.sector_id, + None, + campaign.campaign_to_war_part_id(player), + ) + icon_name = ( + getattr(IconName, f"{base_icon.name}TOKEN") if spent else base_icon + ) + return QIcon(Icons.get_pixmap(icon_name)) + + return ( + compute_icon(battle.player_1_id), + compute_icon(battle.player_2_id), + ) diff --git a/src/warchron/controller/ranking_icon.py b/src/warchron/controller/ranking_icon.py deleted file mode 100644 index 1d9e131..0000000 --- a/src/warchron/controller/ranking_icon.py +++ /dev/null @@ -1,80 +0,0 @@ -from typing import Dict - -from PyQt6.QtGui import QIcon - -from warchron.constants import ( - ContextType, - Icons, - IconName, - VP_RANK_TO_ICON, - NP_RANK_TO_ICON, - ScoreKind, -) -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], - *, - objective_id: str | None = None, - ) -> Dict[str, QIcon]: - - if objective_id is None: - - def value_getter(score: ParticipantScore) -> int: - return score.victory_points - - icon_ranking = VP_RANK_TO_ICON - score_kind = ScoreKind.VP - else: - - def value_getter(score: ParticipantScore) -> int: - return score.narrative_points.get(objective_id, 0) - - icon_ranking = NP_RANK_TO_ICON - score_kind = ScoreKind.NP - ranking = ResultChecker.get_effective_ranking( - war, - context_type, - context_id, - score_kind, - scores, - value_getter, - objective_id, - ) - icon_map: Dict[str, QIcon] = {} - for rank, group, token_map in ranking: - if objective_id and rank not in icon_ranking: - continue - base_icon = icon_ranking.get( - rank, IconName.VPNTH if objective_id is None else None - ) - if base_icon is None: - continue - value = value_getter(scores[group[0]]) - original_group_size = sum( - 1 for s in scores.values() if value_getter(s) == value - ) - 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 3aa4b36..cecba13 100644 --- a/src/warchron/controller/round_controller.py +++ b/src/warchron/controller/round_controller.py @@ -18,7 +18,6 @@ from warchron.model.exception import ( RequiresConfirmation, ) from warchron.model.tie_manager import TieResolver, TieContext -from warchron.model.result_checker import ResultChecker from warchron.model.pairing import Pairing from warchron.model.round import Round from warchron.model.war import War @@ -36,7 +35,7 @@ from warchron.controller.closure_workflow import ( RoundClosureWorkflow, RoundPairingWorkflow, ) -from warchron.controller.presenter import TiePresenter +from warchron.controller.presenter import Presenter from warchron.view.choice_dialog import ChoiceDialog from warchron.view.battle_dialog import BattleDialog from warchron.view.tie_dialog import TieDialog @@ -47,7 +46,6 @@ class RoundController: self.app = app def _fill_round_details(self, round_id: str) -> None: - # self.app.view.clear_round_page() rnd = self.app.model.get_round(round_id) camp = self.app.model.get_campaign_by_round(round_id) war = self.app.model.get_war_by_round(round_id) @@ -135,7 +133,6 @@ class RoundController: player_1_name = self.app.model.get_participant_name( camp_part.war_participant_id ) - p1_id = battle.player_1_id else: player_1_name = "" if battle.player_2_id: @@ -143,7 +140,6 @@ class RoundController: player_2_name = self.app.model.get_participant_name( camp_part.war_participant_id ) - p2_id = battle.player_2_id else: player_2_name = "" if battle.winner_id: @@ -153,34 +149,9 @@ class RoundController: ) else: winner_name = "" - p1_icon = None - p2_icon = None - # TODO use uniform draw/tie icon logic with choice, war, campaign... - if battle.is_draw(): - p1_icon = Icons.get(IconName.DRAW) - p2_icon = Icons.get(IconName.DRAW) - context = TieContext( - ContextType.BATTLE, - battle.sector_id, - [p1_id, p2_id], - ) - if TieResolver.was_tie_broken_by_tokens(war, context): - effective_winner = ResultChecker.get_effective_winner_id( - war, ContextType.BATTLE, battle.sector_id, None - ) - p1_war = None - if battle.player_1_id is not None: - p1_war = camp.campaign_to_war_part_id(battle.player_1_id) - pixmap = Icons.get_pixmap(IconName.TIEBREAK_TOKEN) - if effective_winner == p1_war: - p1_icon = QIcon(pixmap) - else: - p2_icon = QIcon(pixmap) - elif battle.winner_id: - if battle.winner_id == battle.player_1_id: - p1_icon = Icons.get(IconName.WIN) - elif battle.winner_id == battle.player_2_id: - p2_icon = Icons.get(IconName.WIN) + p1_icon, p2_icon = Presenter.compute_battle_icons( + war, round_id, battle.sector_id + ) battles_for_display.append( BattleDTO( id=battle.sector_id, @@ -293,9 +264,7 @@ class RoundController: campaign = war.get_campaign_by_round(ctx.context_id) if campaign: round = war.get_round(ctx.context_id) - data = TiePresenter.build_dialog_data( - war, ctx, round=round, campaign=campaign - ) + data = Presenter.build_dialog_data(war, ctx, round=round, campaign=campaign) dialog = TieDialog( parent=self.app.view, players=players, diff --git a/src/warchron/controller/war_controller.py b/src/warchron/controller/war_controller.py index 8ebd690..48e68e6 100644 --- a/src/warchron/controller/war_controller.py +++ b/src/warchron/controller/war_controller.py @@ -27,8 +27,8 @@ from warchron.model.objective import Objective from warchron.model.tie_manager import TieContext, TieResolver from warchron.model.score_service import ScoreService from warchron.controller.closure_workflow import WarClosureWorkflow -from warchron.controller.ranking_icon import RankingIcon -from warchron.controller.presenter import TiePresenter + +from warchron.controller.presenter import Presenter from warchron.view.war_dialog import WarDialog from warchron.view.objective_dialog import ObjectiveDialog from warchron.view.war_participant_dialog import WarParticipantDialog @@ -62,11 +62,11 @@ class WarController: vp_icon_map: Dict[str, QIcon] = {} objective_icon_maps: Dict[str, Dict[str, QIcon]] = {} if war.is_over: - vp_icon_map = RankingIcon.compute_icons( + vp_icon_map = Presenter.compute_ranking_icons( war, ContextType.WAR, war_id, scores ) for obj in war.get_objectives_used_as_maj_or_min(): - objective_icon_maps[obj.id] = RankingIcon.compute_icons( + objective_icon_maps[obj.id] = Presenter.compute_ranking_icons( war, ContextType.WAR, war.id, @@ -171,7 +171,7 @@ class WarController: for pid in active ] counters = [war.get_influence_tokens(pid) for pid in active] - data = TiePresenter.build_dialog_data( + data = Presenter.build_dialog_data( war, ctx, ) diff --git a/src/warchron/model/pairing.py b/src/warchron/model/pairing.py index fed5234..6371118 100644 --- a/src/warchron/model/pairing.py +++ b/src/warchron/model/pairing.py @@ -16,7 +16,7 @@ from warchron.model.round import Round from warchron.model.battle import Battle from warchron.model.score_service import ScoreService from warchron.model.tie_manager import TieResolver, TieContext -from warchron.model.war_event import TieResolved, InfluenceSpent +from warchron.model.war_event import TieResolved from warchron.model.score_service import ParticipantScore ResolveTiesCallback = Callable[ @@ -304,28 +304,6 @@ class Pairing: return AllocationType.SECONDARY return AllocationType.FALLBACK - @staticmethod - def participant_spent_token( - war: War, - round_id: str, - sector_id: str | None, - war_participant_id: str, - ) -> bool: - if sector_id is None: - return False - for ev in war.events: - if not isinstance(ev, InfluenceSpent): - continue - if ev.context_type != ContextType.CHOICE: - continue - if ev.context_id != round_id: - continue - if ev.sector_id != sector_id: - continue - if ev.participant_id == war_participant_id: - return True - return False - @staticmethod def get_round_allocation( war: War, @@ -338,14 +316,16 @@ class Pairing: raise DomainError(f"No campaign found for round {round.id}") war_pid = campaign.campaign_to_war_part_id(campaign_participant_id) - token_priority = Pairing.participant_spent_token( + token_priority = TieResolver.participant_spent_token( war, + ContextType.CHOICE, round.id, choice.priority_sector_id, war_pid, ) - token_secondary = Pairing.participant_spent_token( + token_secondary = TieResolver.participant_spent_token( war, + ContextType.CHOICE, round.id, choice.secondary_sector_id, war_pid, diff --git a/src/warchron/model/round.py b/src/warchron/model/round.py index 86b279d..522e005 100644 --- a/src/warchron/model/round.py +++ b/src/warchron/model/round.py @@ -5,6 +5,7 @@ from typing import Any, Dict, List, TYPE_CHECKING if TYPE_CHECKING: from warchron.model.campaign import Campaign from warchron.model.war import War + from warchron.constants import ContextType from warchron.model.exception import ( ForbiddenOperation, DomainError, @@ -192,7 +193,7 @@ class Round: victory_condition: str | None, comment: str | None, ) -> None: - from warchron.model.pairing import Pairing + from warchron.model.tie_manager import TieResolver if self.is_over: raise ForbiddenOperation("Can't update battle in a closed round.") @@ -226,8 +227,9 @@ class Round: if ( player and self.has_choice_with_participant(player) - and Pairing.participant_spent_token( + and TieResolver.participant_spent_token( self.war, + ContextType.CHOICE, self.id, sector_id, self.campaign.campaign_to_war_part_id(player), diff --git a/src/warchron/model/tie_manager.py b/src/warchron/model/tie_manager.py index 0d416eb..9605887 100644 --- a/src/warchron/model/tie_manager.py +++ b/src/warchron/model/tie_manager.py @@ -486,3 +486,26 @@ class TieResolver: continue return True return False + + @staticmethod + def participant_spent_token( + war: War, + context_type: ContextType, + context_id: str, + sector_id: str | None, + war_participant_id: str, + ) -> bool: + if context_type == ContextType.CHOICE and sector_id is None: + return False + for ev in war.events: + if not isinstance(ev, InfluenceSpent): + continue + if ev.context_type != context_type: + continue + if ev.context_id != context_id: + continue + if ev.sector_id != sector_id: + continue + if ev.participant_id == war_participant_id: + return True + return False diff --git a/src/warchron/view/view.py b/src/warchron/view/view.py index 53fab4b..80ac522 100644 --- a/src/warchron/view/view.py +++ b/src/warchron/view/view.py @@ -599,12 +599,6 @@ class View(QtWidgets.QMainWindow, Ui_MainWindow): def show_round_details(self, *, index: int | None) -> None: self.roundNb.setText(f"Round {index}") - def clear_round_page(self) -> None: - choices_table = self.choicesTable - choices_table.clearContents() - battles_table = self.battlesTable - battles_table.clearContents() - def display_round_choices(self, participants: List[ChoiceDTO]) -> None: table = self.choicesTable table.setSortingEnabled(False) From 956fa63c0ba308f7d10324233a07dc7611b928eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20R=C3=A9aux?= Date: Thu, 19 Mar 2026 15:23:50 +0100 Subject: [PATCH 07/10] refacto file and class names --- .../controller/campaign_controller.py | 12 +-- src/warchron/controller/presenter.py | 8 +- src/warchron/controller/round_controller.py | 6 +- src/warchron/controller/war_controller.py | 12 +-- .../{closure_workflow.py => workflows.py} | 76 +++++++++---------- .../model/{result_checker.py => checking.py} | 20 ++--- .../model/{closure_service.py => closing.py} | 4 +- src/warchron/model/pairing.py | 28 +++---- src/warchron/model/round.py | 4 +- .../model/{score_service.py => scoring.py} | 6 +- .../model/{tie_manager.py => tiebreaking.py} | 52 ++++++------- test_data/example.json | 24 +++--- 12 files changed, 126 insertions(+), 126 deletions(-) rename src/warchron/controller/{closure_workflow.py => workflows.py} (50%) rename src/warchron/model/{result_checker.py => checking.py} (88%) rename src/warchron/model/{closure_service.py => closing.py} (96%) rename src/warchron/model/{score_service.py => scoring.py} (95%) rename src/warchron/model/{tie_manager.py => tiebreaking.py} (90%) diff --git a/src/warchron/controller/campaign_controller.py b/src/warchron/controller/campaign_controller.py index b688bfd..1775509 100644 --- a/src/warchron/controller/campaign_controller.py +++ b/src/warchron/controller/campaign_controller.py @@ -19,9 +19,9 @@ from warchron.model.war import War from warchron.model.campaign import Campaign from warchron.model.campaign_participant import CampaignParticipant from warchron.model.sector import Sector -from warchron.model.tie_manager import TieContext, TieResolver -from warchron.model.score_service import ScoreService -from warchron.controller.closure_workflow import CampaignClosureWorkflow +from warchron.model.tiebreaking import TieContext, TieBreaker +from warchron.model.scoring import ScoreComputer +from warchron.controller.workflows import CampaignClosureWorkflow from warchron.controller.presenter import Presenter from warchron.view.campaign_dialog import CampaignDialog @@ -53,7 +53,7 @@ class CampaignController: for sect in sectors ] self.app.view.display_campaign_sectors(sectors_for_display) - scores = ScoreService.compute_scores(war, ContextType.CAMPAIGN, campaign_id) + scores = ScoreComputer.compute_scores(war, ContextType.CAMPAIGN, campaign_id) rows: List[CampaignParticipantScoreDTO] = [] vp_icon_map: Dict[str, QIcon] = {} objective_icon_maps: Dict[str, Dict[str, QIcon]] = {} @@ -171,7 +171,7 @@ class CampaignController: ) -> Dict[tuple[str, str, int | None, str | None, str | None], Dict[str, bool]]: bids_map = {} for ctx in contexts: - active = TieResolver.get_active_participants(war, ctx, ctx.participants) + active = TieBreaker.get_active_participants(war, ctx, ctx.participants) players = [ ParticipantOption(id=pid, name=self.app.model.get_participant_name(pid)) for pid in active @@ -189,7 +189,7 @@ class CampaignController: context_name=data.title, ) if not dialog.exec(): - TieResolver.cancel_tie_break(war, ctx) + TieBreaker.cancel_tie_break(war, ctx) raise AbortedOperation("Tie resolution cancelled") bids_map[ctx.key()] = dialog.get_bids() return bids_map diff --git a/src/warchron/controller/presenter.py b/src/warchron/controller/presenter.py index a90be32..09362cc 100644 --- a/src/warchron/controller/presenter.py +++ b/src/warchron/controller/presenter.py @@ -12,12 +12,12 @@ from warchron.constants import ( ) from warchron.controller.dtos import TieDialogData -from warchron.model.tie_manager import TieContext, TieResolver +from warchron.model.tiebreaking import TieContext, TieBreaker from warchron.model.war import War from warchron.model.campaign import Campaign from warchron.model.round import Round -from warchron.model.score_service import ParticipantScore -from warchron.model.result_checker import ResultChecker +from warchron.model.scoring import ParticipantScore +from warchron.model.checking import ResultChecker from warchron.model.exception import DomainError @@ -160,7 +160,7 @@ class Presenter: base_icon = IconName.TIEBREAK if base_icon is None: return None - spent = TieResolver.participant_spent_token( + spent = TieBreaker.participant_spent_token( war, ContextType.BATTLE, battle.sector_id, diff --git a/src/warchron/controller/round_controller.py b/src/warchron/controller/round_controller.py index cecba13..4af904d 100644 --- a/src/warchron/controller/round_controller.py +++ b/src/warchron/controller/round_controller.py @@ -17,7 +17,7 @@ from warchron.model.exception import ( DomainError, RequiresConfirmation, ) -from warchron.model.tie_manager import TieResolver, TieContext +from warchron.model.tiebreaking import TieBreaker, TieContext from warchron.model.pairing import Pairing from warchron.model.round import Round from warchron.model.war import War @@ -31,7 +31,7 @@ from warchron.controller.dtos import ( ChoiceDTO, BattleDTO, ) -from warchron.controller.closure_workflow import ( +from warchron.controller.workflows import ( RoundClosureWorkflow, RoundPairingWorkflow, ) @@ -274,7 +274,7 @@ class RoundController: context_name=data.title, ) if not dialog.exec(): - TieResolver.cancel_tie_break(war, ctx) + TieBreaker.cancel_tie_break(war, ctx) raise AbortedOperation("Tie resolution cancelled") bids_map[ctx.key()] = dialog.get_bids() return bids_map diff --git a/src/warchron/controller/war_controller.py b/src/warchron/controller/war_controller.py index 48e68e6..c6bb127 100644 --- a/src/warchron/controller/war_controller.py +++ b/src/warchron/controller/war_controller.py @@ -24,9 +24,9 @@ from warchron.controller.dtos import ( from warchron.model.war import War 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.controller.closure_workflow import WarClosureWorkflow +from warchron.model.tiebreaking import TieContext, TieBreaker +from warchron.model.scoring import ScoreComputer +from warchron.controller.workflows import WarClosureWorkflow from warchron.controller.presenter import Presenter from warchron.view.war_dialog import WarDialog @@ -57,7 +57,7 @@ class WarController: ObjectiveDTO(id=obj.id, name=obj.name, description=obj.description) for obj in war.get_objectives_used_as_maj_or_min() ] - scores = ScoreService.compute_scores(war, ContextType.WAR, war.id) + scores = ScoreComputer.compute_scores(war, ContextType.WAR, war.id) rows: List[WarParticipantScoreDTO] = [] vp_icon_map: Dict[str, QIcon] = {} objective_icon_maps: Dict[str, Dict[str, QIcon]] = {} @@ -161,7 +161,7 @@ class WarController: ) -> Dict[tuple[str, str, int | None, str | None, str | None], Dict[str, bool]]: bids_map = {} for ctx in contexts: - active = TieResolver.get_active_participants( + active = TieBreaker.get_active_participants( war, ctx, ctx.participants, @@ -184,7 +184,7 @@ class WarController: context_name=data.title, ) if not dialog.exec(): - TieResolver.cancel_tie_break(war, ctx) + TieBreaker.cancel_tie_break(war, ctx) raise AbortedOperation("Tie resolution cancelled") bids_map[ctx.key()] = dialog.get_bids() return bids_map diff --git a/src/warchron/controller/closure_workflow.py b/src/warchron/controller/workflows.py similarity index 50% rename from src/warchron/controller/closure_workflow.py rename to src/warchron/controller/workflows.py index f824d26..4663fd0 100644 --- a/src/warchron/controller/closure_workflow.py +++ b/src/warchron/controller/workflows.py @@ -6,51 +6,51 @@ if TYPE_CHECKING: from warchron.model.war import War 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.closing import Closer +from warchron.model.tiebreaking import TieBreaker from warchron.model.pairing import Pairing -class ClosureWorkflow: +class Workflow: def __init__(self, controller: "AppController"): self.app = controller -class RoundClosureWorkflow(ClosureWorkflow): +class RoundClosureWorkflow(Workflow): def start(self, war: War, campaign: Campaign, round: Round) -> None: - ClosureService.check_round_closable(round) - ties = TieResolver.find_battle_ties(war, round.id) + Closer.check_round_closable(round) + ties = TieBreaker.find_battle_ties(war, round.id) while ties: bids_map = self.app.rounds.resolve_ties(war, ties) for tie in ties: bids = bids_map[tie.key()] - tie_id = TieResolver.find_active_tie_id(war, tie) or str(uuid4()) - TieResolver.apply_bids(war, tie, tie_id, bids) - TieResolver.resolve_tie_state(war, tie, tie_id, bids) - ties = TieResolver.find_battle_ties(war, round.id) + tie_id = TieBreaker.find_active_tie_id(war, tie) or str(uuid4()) + TieBreaker.apply_bids(war, tie, tie_id, bids) + TieBreaker.resolve_tie_state(war, tie, tie_id, bids) + ties = TieBreaker.find_battle_ties(war, round.id) for battle in round.battles.values(): - ClosureService.apply_battle_outcomes(war, campaign, battle) - ClosureService.finalize_round(round) + Closer.apply_battle_outcomes(war, campaign, battle) + Closer.finalize_round(round) -class CampaignClosureWorkflow(ClosureWorkflow): +class CampaignClosureWorkflow(Workflow): def start(self, war: War, campaign: Campaign) -> None: - ClosureService.check_campaign_closable(campaign) - ties = TieResolver.find_campaign_ties(war, campaign.id) + Closer.check_campaign_closable(campaign) + ties = TieBreaker.find_campaign_ties(war, campaign.id) while ties: bids_map = self.app.campaigns.resolve_ties(war, ties) for tie in ties: bids = bids_map[tie.key()] - tie_id = TieResolver.find_active_tie_id(war, tie) or str(uuid4()) - TieResolver.apply_bids(war, tie, tie_id, bids) - TieResolver.resolve_tie_state(war, tie, tie_id, bids) - ties = TieResolver.find_campaign_ties(war, campaign.id) + tie_id = TieBreaker.find_active_tie_id(war, tie) or str(uuid4()) + TieBreaker.apply_bids(war, tie, tie_id, bids) + TieBreaker.resolve_tie_state(war, tie, tie_id, bids) + ties = TieBreaker.find_campaign_ties(war, campaign.id) for obj in war.get_objectives_used_as_maj_or_min(): objective_id = obj.id - ties = TieResolver.find_campaign_objective_ties( + ties = TieBreaker.find_campaign_objective_ties( war, campaign.id, objective_id, @@ -59,33 +59,33 @@ class CampaignClosureWorkflow(ClosureWorkflow): bids_map = self.app.campaigns.resolve_ties(war, ties) for tie in ties: bids = bids_map[tie.key()] - tie_id = TieResolver.find_active_tie_id(war, tie) or str(uuid4()) - TieResolver.apply_bids(war, tie, tie_id, bids) - TieResolver.resolve_tie_state(war, tie, tie_id, bids) - ties = TieResolver.find_campaign_objective_ties( + tie_id = TieBreaker.find_active_tie_id(war, tie) or str(uuid4()) + TieBreaker.apply_bids(war, tie, tie_id, bids) + TieBreaker.resolve_tie_state(war, tie, tie_id, bids) + ties = TieBreaker.find_campaign_objective_ties( war, campaign.id, objective_id, ) - ClosureService.finalize_campaign(campaign) + Closer.finalize_campaign(campaign) -class WarClosureWorkflow(ClosureWorkflow): +class WarClosureWorkflow(Workflow): def start(self, war: War) -> None: - ClosureService.check_war_closable(war) - ties = TieResolver.find_war_ties(war) + Closer.check_war_closable(war) + ties = TieBreaker.find_war_ties(war) while ties: bids_map = self.app.wars.resolve_ties(war, ties) for tie in ties: bids = bids_map[tie.key()] - tie_id = TieResolver.find_active_tie_id(war, tie) or str(uuid4()) - TieResolver.apply_bids(war, tie, tie_id, bids) - TieResolver.resolve_tie_state(war, tie, tie_id, bids) - ties = TieResolver.find_war_ties(war) + tie_id = TieBreaker.find_active_tie_id(war, tie) or str(uuid4()) + TieBreaker.apply_bids(war, tie, tie_id, bids) + TieBreaker.resolve_tie_state(war, tie, tie_id, bids) + ties = TieBreaker.find_war_ties(war) for obj in war.get_objectives_used_as_maj_or_min(): objective_id = obj.id - ties = TieResolver.find_war_objective_ties( + ties = TieBreaker.find_war_objective_ties( war, objective_id, ) @@ -93,14 +93,14 @@ class WarClosureWorkflow(ClosureWorkflow): bids_map = self.app.wars.resolve_ties(war, ties) for tie in ties: bids = bids_map[tie.key()] - tie_id = TieResolver.find_active_tie_id(war, tie) or str(uuid4()) - TieResolver.apply_bids(war, tie, tie_id, bids) - TieResolver.resolve_tie_state(war, tie, tie_id, bids) - ties = TieResolver.find_war_objective_ties( + tie_id = TieBreaker.find_active_tie_id(war, tie) or str(uuid4()) + TieBreaker.apply_bids(war, tie, tie_id, bids) + TieBreaker.resolve_tie_state(war, tie, tie_id, bids) + ties = TieBreaker.find_war_objective_ties( war, objective_id, ) - ClosureService.finalize_war(war) + Closer.finalize_war(war) class RoundPairingWorkflow: diff --git a/src/warchron/model/result_checker.py b/src/warchron/model/checking.py similarity index 88% rename from src/warchron/model/result_checker.py rename to src/warchron/model/checking.py index bbfe190..4331762 100644 --- a/src/warchron/model/result_checker.py +++ b/src/warchron/model/checking.py @@ -5,10 +5,10 @@ from collections import defaultdict from warchron.constants import ContextType, ScoreKind from warchron.model.war import War from warchron.model.war_event import TieResolved -from warchron.model.tie_manager import TieResolver, TieContext +from warchron.model.tiebreaking import TieBreaker, TieContext if TYPE_CHECKING: - from warchron.model.score_service import ParticipantScore + from warchron.model.scoring import ParticipantScore class ResultChecker: @@ -75,29 +75,29 @@ class ResultChecker: current_rank += 1 continue # normal tie-break if tie persists - if not TieResolver.is_tie_resolved(war, context): + if not TieBreaker.is_tie_resolved(war, context): ranking.append( (current_rank, subgroup, {pid: 0 for pid in subgroup}) ) current_rank += 1 continue - groups = TieResolver.rank_by_tokens(war, context, subgroup) - tokens_spent = TieResolver.tokens_spent_map(war, context, subgroup) + groups = TieBreaker.rank_by_tokens(war, context, subgroup) + tokens_spent = TieBreaker.tokens_spent_map(war, context, 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): + if len(participants) == 1 or not TieBreaker.is_tie_resolved(war, context): ranking.append( (current_rank, participants, {pid: 0 for pid in participants}) ) current_rank += 1 continue # apply token ranking - groups = TieResolver.rank_by_tokens(war, context, participants) - tokens_spent = TieResolver.tokens_spent_map(war, context, participants) + groups = TieBreaker.rank_by_tokens(war, context, participants) + tokens_spent = TieBreaker.tokens_spent_map(war, context, participants) for group in groups: group_tokens = {pid: tokens_spent[pid] for pid in group} ranking.append((current_rank, group, group_tokens)) @@ -111,13 +111,13 @@ class ResultChecker: value_getter: Callable[[ParticipantScore], int], subcontexts: List[TieContext], ) -> List[List[str]]: - from warchron.model.score_service import ScoreService + from warchron.model.scoring import ScoreComputer rank_map: Dict[str, Tuple[int, ...]] = {} for pid in participants: ranks: List[int] = [] for sub in subcontexts: - scores = ScoreService.compute_scores( + scores = ScoreComputer.compute_scores( war, sub.context_type, sub.context_id ) ranking = ResultChecker.get_effective_ranking( diff --git a/src/warchron/model/closure_service.py b/src/warchron/model/closing.py similarity index 96% rename from src/warchron/model/closure_service.py rename to src/warchron/model/closing.py index c943060..0b70b39 100644 --- a/src/warchron/model/closure_service.py +++ b/src/warchron/model/closing.py @@ -9,7 +9,7 @@ from warchron.model.round import Round from warchron.model.battle import Battle -class ClosureService: +class Closer: # Round methods @@ -24,7 +24,7 @@ class ClosureService: @staticmethod def apply_battle_outcomes(war: War, campaign: Campaign, battle: Battle) -> None: - from warchron.model.result_checker import ResultChecker + from warchron.model.checking import ResultChecker already_granted = any( isinstance(e, InfluenceGained) and e.context_id == battle.sector_id diff --git a/src/warchron/model/pairing.py b/src/warchron/model/pairing.py index 6371118..a186220 100644 --- a/src/warchron/model/pairing.py +++ b/src/warchron/model/pairing.py @@ -14,10 +14,10 @@ from warchron.model.exception import ( 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 -from warchron.model.tie_manager import TieResolver, TieContext +from warchron.model.scoring import ScoreComputer +from warchron.model.tiebreaking import TieBreaker, TieContext from warchron.model.war_event import TieResolved -from warchron.model.score_service import ParticipantScore +from warchron.model.scoring import ParticipantScore ResolveTiesCallback = Callable[ ["War", List["TieContext"]], @@ -84,7 +84,7 @@ class Pairing: 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( + scores = ScoreComputer.compute_scores( war, ContextType.CAMPAIGN, campaign.id, @@ -93,7 +93,7 @@ class Pairing: def value_getter(score: ParticipantScore) -> int: return score.victory_points - score_groups = ScoreService.group_participants_by_score(scores, value_getter) + score_groups = ScoreComputer.group_participants_by_score(scores, value_getter) sector_to_battle: Dict[str, Battle] = { b.sector_id: b for b in round.battles.values() } @@ -206,9 +206,9 @@ class Pairing: sector_id=sector_id, ) # ---- resolve tie loop ---- - tie_id = TieResolver.find_active_tie_id(war, context) or str(uuid4()) - while not TieResolver.is_tie_resolved(war, context): - active = TieResolver.get_active_participants( + tie_id = TieBreaker.find_active_tie_id(war, context) or str(uuid4()) + while not TieBreaker.is_tie_resolved(war, context): + active = TieBreaker.get_active_participants( war, context, context.participants ) current_context = TieContext( @@ -221,7 +221,7 @@ class Pairing: ) # natural, unbreakable or acceptable (enough places) draw if ( - not TieResolver.can_tie_be_resolved( + not TieBreaker.can_tie_be_resolved( war, context, current_context.participants ) or len(active) <= places @@ -254,9 +254,9 @@ class Pairing: ) ) break - TieResolver.apply_bids(war, context, tie_id, bids) - TieResolver.resolve_tie_state(war, context, tie_id, bids) - ranked_groups = TieResolver.rank_by_tokens( + TieBreaker.apply_bids(war, context, tie_id, bids) + TieBreaker.resolve_tie_state(war, context, tie_id, bids) + ranked_groups = TieBreaker.rank_by_tokens( war, context, context.participants, @@ -316,14 +316,14 @@ class Pairing: raise DomainError(f"No campaign found for round {round.id}") war_pid = campaign.campaign_to_war_part_id(campaign_participant_id) - token_priority = TieResolver.participant_spent_token( + token_priority = TieBreaker.participant_spent_token( war, ContextType.CHOICE, round.id, choice.priority_sector_id, war_pid, ) - token_secondary = TieResolver.participant_spent_token( + token_secondary = TieBreaker.participant_spent_token( war, ContextType.CHOICE, round.id, diff --git a/src/warchron/model/round.py b/src/warchron/model/round.py index 522e005..0e4be1e 100644 --- a/src/warchron/model/round.py +++ b/src/warchron/model/round.py @@ -193,7 +193,7 @@ class Round: victory_condition: str | None, comment: str | None, ) -> None: - from warchron.model.tie_manager import TieResolver + from warchron.model.tiebreaking import TieBreaker if self.is_over: raise ForbiddenOperation("Can't update battle in a closed round.") @@ -227,7 +227,7 @@ class Round: if ( player and self.has_choice_with_participant(player) - and TieResolver.participant_spent_token( + and TieBreaker.participant_spent_token( self.war, ContextType.CHOICE, self.id, diff --git a/src/warchron/model/score_service.py b/src/warchron/model/scoring.py similarity index 95% rename from src/warchron/model/score_service.py rename to src/warchron/model/scoring.py index 58fc07e..6937371 100644 --- a/src/warchron/model/score_service.py +++ b/src/warchron/model/scoring.py @@ -15,7 +15,7 @@ class ParticipantScore: narrative_points: Dict[str, int] = field(default_factory=dict) -class ScoreService: +class ScoreComputer: @staticmethod def _get_battles_for_context( @@ -47,7 +47,7 @@ class ScoreService: def compute_scores( war: War, context_type: ContextType, context_id: str ) -> Dict[str, ParticipantScore]: - from warchron.model.result_checker import ResultChecker + from warchron.model.checking import ResultChecker if context_type == ContextType.CAMPAIGN: camp = war.get_campaign(context_id) @@ -61,7 +61,7 @@ class ScoreService: ) for pid in participant_ids } - battles = ScoreService._get_battles_for_context(war, context_type, context_id) + battles = ScoreComputer._get_battles_for_context(war, context_type, context_id) for battle in battles: base_winner = None if battle.winner_id is not None: diff --git a/src/warchron/model/tie_manager.py b/src/warchron/model/tiebreaking.py similarity index 90% rename from src/warchron/model/tie_manager.py rename to src/warchron/model/tiebreaking.py index 9605887..8b47ec8 100644 --- a/src/warchron/model/tie_manager.py +++ b/src/warchron/model/tiebreaking.py @@ -6,7 +6,7 @@ from warchron.constants import ContextType, ScoreKind from warchron.model.exception import ForbiddenOperation, DomainError from warchron.model.war import War from warchron.model.war_event import InfluenceSpent, TieResolved -from warchron.model.score_service import ScoreService, ParticipantScore +from warchron.model.scoring import ScoreComputer, ParticipantScore @dataclass @@ -29,7 +29,7 @@ class TieContext: ) -class TieResolver: +class TieBreaker: @staticmethod def find_active_tie_id( @@ -65,10 +65,10 @@ class TieResolver: context: TieContext = TieContext( ContextType.BATTLE, battle.sector_id, [p1_id, p2_id] ) - if TieResolver.is_tie_resolved(war, context): + if TieBreaker.is_tie_resolved(war, context): continue - tie_id = TieResolver.find_active_tie_id(war, context) - if not TieResolver.can_tie_be_resolved(war, context, [p1_id, p2_id]): + tie_id = TieBreaker.find_active_tie_id(war, context) + if not TieBreaker.can_tie_be_resolved(war, context, [p1_id, p2_id]): war.events.append( TieResolved( participant_id=None, @@ -92,7 +92,7 @@ class TieResolver: @staticmethod def find_campaign_ties(war: War, campaign_id: str) -> List[TieContext]: - scores = ScoreService.compute_scores(war, ContextType.CAMPAIGN, campaign_id) + scores = ScoreComputer.compute_scores(war, ContextType.CAMPAIGN, campaign_id) buckets: DefaultDict[int, List[str]] = defaultdict(list) for pid, score in scores.items(): buckets[score.victory_points].append(pid) @@ -107,10 +107,10 @@ class TieResolver: score_value, ScoreKind.VP, ) - if TieResolver.is_tie_resolved(war, context): + if TieBreaker.is_tie_resolved(war, context): continue - tie_id = TieResolver.find_active_tie_id(war, context) - if not TieResolver.can_tie_be_resolved(war, context, participants): + tie_id = TieBreaker.find_active_tie_id(war, context) + if not TieBreaker.can_tie_be_resolved(war, context, participants): war.events.append( TieResolved( participant_id=None, @@ -137,7 +137,7 @@ class TieResolver: def find_campaign_objective_ties( war: War, campaign_id: str, objective_id: str ) -> List[TieContext]: - scores = ScoreService.compute_scores( + scores = ScoreComputer.compute_scores( war, ContextType.CAMPAIGN, campaign_id, @@ -159,10 +159,10 @@ class TieResolver: ScoreKind.NP, objective_id, ) - if TieResolver.is_tie_resolved(war, context): + if TieBreaker.is_tie_resolved(war, context): continue - tie_id = TieResolver.find_active_tie_id(war, context) - if not TieResolver.can_tie_be_resolved( + tie_id = TieBreaker.find_active_tie_id(war, context) + if not TieBreaker.can_tie_be_resolved( war, context, participants, @@ -193,9 +193,9 @@ class TieResolver: @staticmethod def find_war_ties(war: War) -> List[TieContext]: - from warchron.model.result_checker import ResultChecker + from warchron.model.checking import ResultChecker - scores = ScoreService.compute_scores(war, ContextType.WAR, war.id) + scores = ScoreComputer.compute_scores(war, ContextType.WAR, war.id) ranking = ResultChecker.get_effective_ranking( war, ContextType.WAR, @@ -216,10 +216,10 @@ class TieResolver: score_value=score_value, score_kind=ScoreKind.VP, ) - if TieResolver.is_tie_resolved(war, context): + if TieBreaker.is_tie_resolved(war, context): continue - tie_id = TieResolver.find_active_tie_id(war, context) - if not TieResolver.can_tie_be_resolved(war, context, group): + tie_id = TieBreaker.find_active_tie_id(war, context) + if not TieBreaker.can_tie_be_resolved(war, context, group): war.events.append( TieResolved( participant_id=None, @@ -244,9 +244,9 @@ class TieResolver: @staticmethod def find_war_objective_ties(war: War, objective_id: str) -> List[TieContext]: - from warchron.model.result_checker import ResultChecker + from warchron.model.checking import ResultChecker - scores = ScoreService.compute_scores( + scores = ScoreComputer.compute_scores( war, ContextType.WAR, war.id, @@ -272,13 +272,13 @@ class TieResolver: context: TieContext = TieContext( ContextType.WAR, war.id, [], np_value, ScoreKind.NP, objective_id ) - if TieResolver.is_tie_resolved( + if TieBreaker.is_tie_resolved( war, context, ): continue - tie_id = TieResolver.find_active_tie_id(war, context) - if not TieResolver.can_tie_be_resolved( + tie_id = TieBreaker.find_active_tie_id(war, context) + if not TieBreaker.can_tie_be_resolved( war, context, group, @@ -398,7 +398,7 @@ class TieResolver: context: TieContext, participants: List[str], ) -> List[str]: - groups = TieResolver.rank_by_tokens(war, context, participants) + groups = TieBreaker.rank_by_tokens(war, context, participants) return groups[0] @staticmethod @@ -423,7 +423,7 @@ class TieResolver: ) ) return - groups = TieResolver.rank_by_tokens(war, context, context.participants) + groups = TieBreaker.rank_by_tokens(war, context, context.participants) if len(groups[0]) == 1: war.events.append( TieResolved( @@ -444,7 +444,7 @@ class TieResolver: def can_tie_be_resolved( war: War, context: TieContext, participants: List[str] ) -> bool: - active = TieResolver.get_active_participants(war, context, participants) + active = TieBreaker.get_active_participants(war, context, participants) return any(war.get_influence_tokens(pid) > 0 for pid in active) @staticmethod diff --git a/test_data/example.json b/test_data/example.json index 7b8484a..c66635a 100644 --- a/test_data/example.json +++ b/test_data/example.json @@ -273,44 +273,44 @@ "choices": [ { "participant_id": "602e2eaf-297e-490b-b0e9-efec818e466a", - "priority_sector_id": null, - "secondary_sector_id": null, + "priority_sector_id": "d1f7c6cf-40ff-42b8-b1b6-05576017de1f", + "secondary_sector_id": "096da98e-d8b4-4c71-946a-3cee2cc38499", "comment": null }, { "participant_id": "1f6b4e7c-b1e4-4a2e-9aea-7bb75b20b4de", - "priority_sector_id": null, - "secondary_sector_id": null, + "priority_sector_id": "096da98e-d8b4-4c71-946a-3cee2cc38499", + "secondary_sector_id": "d1f7c6cf-40ff-42b8-b1b6-05576017de1f", "comment": null }, { "participant_id": "237c1291-4331-4242-bd70-bf648185a627", - "priority_sector_id": null, - "secondary_sector_id": null, + "priority_sector_id": "d1f7c6cf-40ff-42b8-b1b6-05576017de1f", + "secondary_sector_id": "096da98e-d8b4-4c71-946a-3cee2cc38499", "comment": null }, { "participant_id": "876a1684-7fe2-4d8d-a50a-a4d7e354c5b0", - "priority_sector_id": null, - "secondary_sector_id": null, + "priority_sector_id": "096da98e-d8b4-4c71-946a-3cee2cc38499", + "secondary_sector_id": "d1f7c6cf-40ff-42b8-b1b6-05576017de1f", "comment": null } ], "battles": [ { "sector_id": "d1f7c6cf-40ff-42b8-b1b6-05576017de1f", - "player_1_id": "602e2eaf-297e-490b-b0e9-efec818e466a", - "player_2_id": "237c1291-4331-4242-bd70-bf648185a627", + "player_1_id": "237c1291-4331-4242-bd70-bf648185a627", + "player_2_id": "602e2eaf-297e-490b-b0e9-efec818e466a", "winner_id": null, "score": null, - "victory_condition": "tie", + "victory_condition": "Tie", "comment": "Never finished..." }, { "sector_id": "096da98e-d8b4-4c71-946a-3cee2cc38499", "player_1_id": "1f6b4e7c-b1e4-4a2e-9aea-7bb75b20b4de", "player_2_id": "876a1684-7fe2-4d8d-a50a-a4d7e354c5b0", - "winner_id": "876a1684-7fe2-4d8d-a50a-a4d7e354c5b0", + "winner_id": "1f6b4e7c-b1e4-4a2e-9aea-7bb75b20b4de", "score": "4/2", "victory_condition": "Mission", "comment": "Decisive fast attack impossible to resist." From e7e2de9d0abd5b9a9ef612164d7c52d2e63aa5db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20R=C3=A9aux?= Date: Thu, 19 Mar 2026 15:28:30 +0100 Subject: [PATCH 08/10] display choice comment --- src/warchron/view/view.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/warchron/view/view.py b/src/warchron/view/view.py index 80ac522..74f766f 100644 --- a/src/warchron/view/view.py +++ b/src/warchron/view/view.py @@ -603,8 +603,10 @@ class View(QtWidgets.QMainWindow, Ui_MainWindow): table = self.choicesTable table.setSortingEnabled(False) table.clearContents() - table.setColumnCount(4) - table.setHorizontalHeaderLabels(["Participant", "Priority", "Secondary", ""]) + table.setColumnCount(5) + table.setHorizontalHeaderLabels( + ["Participant", "Priority", "Secondary", "", "Comment"] + ) table.setRowCount(len(participants)) table.setIconSize(QSize(32, 16)) for row, choice in enumerate(participants): @@ -618,11 +620,13 @@ class View(QtWidgets.QMainWindow, Ui_MainWindow): status_item = QtWidgets.QTableWidgetItem() if choice.fallback_icon: status_item.setIcon(choice.fallback_icon) + comment_item = QtWidgets.QTableWidgetItem(choice.comment) participant_item.setData(Qt.ItemDataRole.UserRole, choice.id) table.setItem(row, 0, participant_item) table.setItem(row, 1, priority_item) table.setItem(row, 2, secondary_item) table.setItem(row, 3, status_item) + table.setItem(row, 4, comment_item) table.setSortingEnabled(True) table.resizeColumnsToContents() From 719b0128ed76469bbf2a5c07d779df5c443d1822 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20R=C3=A9aux?= Date: Thu, 19 Mar 2026 16:20:34 +0100 Subject: [PATCH 09/10] fix refresh choice & battle tables + refactor loops --- src/warchron/controller/round_controller.py | 4 +- src/warchron/controller/workflows.py | 5 +++ src/warchron/model/campaign.py | 12 ++++-- src/warchron/model/round.py | 2 +- src/warchron/model/war.py | 44 ++++++++------------- 5 files changed, 32 insertions(+), 35 deletions(-) diff --git a/src/warchron/controller/round_controller.py b/src/warchron/controller/round_controller.py index 4af904d..097e0c8 100644 --- a/src/warchron/controller/round_controller.py +++ b/src/warchron/controller/round_controller.py @@ -66,7 +66,7 @@ class RoundController: "Create forbidden", str(e), ) - else: + if choice: priority_name = ( camp.get_sector_name(choice.priority_sector_id) if choice.priority_sector_id is not None @@ -124,7 +124,7 @@ class RoundController: "Create forbidden", str(e), ) - else: + if battle: state_icon = Icons.get(IconName.ONGOING) if battle.is_finished(): state_icon = Icons.get(IconName.DONE) diff --git a/src/warchron/controller/workflows.py b/src/warchron/controller/workflows.py index 4663fd0..37a692f 100644 --- a/src/warchron/controller/workflows.py +++ b/src/warchron/controller/workflows.py @@ -27,6 +27,7 @@ class RoundClosureWorkflow(Workflow): for tie in ties: bids = bids_map[tie.key()] tie_id = TieBreaker.find_active_tie_id(war, tie) or str(uuid4()) + # TODO finish tiebreak by group (like choice) not by turn TieBreaker.apply_bids(war, tie, tie_id, bids) TieBreaker.resolve_tie_state(war, tie, tie_id, bids) ties = TieBreaker.find_battle_ties(war, round.id) @@ -45,6 +46,7 @@ class CampaignClosureWorkflow(Workflow): for tie in ties: bids = bids_map[tie.key()] tie_id = TieBreaker.find_active_tie_id(war, tie) or str(uuid4()) + # TODO finish tiebreak by group (like choice) not by turn TieBreaker.apply_bids(war, tie, tie_id, bids) TieBreaker.resolve_tie_state(war, tie, tie_id, bids) ties = TieBreaker.find_campaign_ties(war, campaign.id) @@ -60,6 +62,7 @@ class CampaignClosureWorkflow(Workflow): for tie in ties: bids = bids_map[tie.key()] tie_id = TieBreaker.find_active_tie_id(war, tie) or str(uuid4()) + # TODO finish tiebreak by group (like choice) not by turn TieBreaker.apply_bids(war, tie, tie_id, bids) TieBreaker.resolve_tie_state(war, tie, tie_id, bids) ties = TieBreaker.find_campaign_objective_ties( @@ -80,6 +83,7 @@ class WarClosureWorkflow(Workflow): for tie in ties: bids = bids_map[tie.key()] tie_id = TieBreaker.find_active_tie_id(war, tie) or str(uuid4()) + # TODO finish tiebreak by group (like choice) not by turn TieBreaker.apply_bids(war, tie, tie_id, bids) TieBreaker.resolve_tie_state(war, tie, tie_id, bids) ties = TieBreaker.find_war_ties(war) @@ -94,6 +98,7 @@ class WarClosureWorkflow(Workflow): for tie in ties: bids = bids_map[tie.key()] tie_id = TieBreaker.find_active_tie_id(war, tie) or str(uuid4()) + # TODO finish tiebreak by group (like choice) not by turn TieBreaker.apply_bids(war, tie, tie_id, bids) TieBreaker.resolve_tie_state(war, tie, tie_id, bids) ties = TieBreaker.find_war_objective_ties( diff --git a/src/warchron/model/campaign.py b/src/warchron/model/campaign.py index ee2a136..17b6316 100644 --- a/src/warchron/model/campaign.py +++ b/src/warchron/model/campaign.py @@ -345,12 +345,10 @@ class Campaign: return index raise KeyError("Round not found in campaign") - # TODO replace multiloops by internal has_* method def get_round_by_battle(self, sector_id: str) -> Round: for rnd in self.rounds: - for bat in rnd.battles.values(): - if bat.sector_id == sector_id: - return rnd + if rnd.has_battle_with_sector(sector_id): + return rnd raise KeyError(f"Battle {sector_id} not found in any Round") def get_all_rounds(self) -> List[Round]: @@ -408,6 +406,12 @@ class Campaign: rnd = self.get_round(round_id) return rnd.create_battle(sector_id) + def get_battle(self, battle_id: str) -> Battle | None: + for rnd in self.rounds: + if rnd.has_battle_with_sector(battle_id): + return rnd.get_battle(battle_id) + return None + def update_battle( self, round_id: str, diff --git a/src/warchron/model/round.py b/src/warchron/model/round.py index 0e4be1e..ce4c73b 100644 --- a/src/warchron/model/round.py +++ b/src/warchron/model/round.py @@ -5,7 +5,7 @@ from typing import Any, Dict, List, TYPE_CHECKING if TYPE_CHECKING: from warchron.model.campaign import Campaign from warchron.model.war import War - from warchron.constants import ContextType +from warchron.constants import ContextType from warchron.model.exception import ( ForbiddenOperation, DomainError, diff --git a/src/warchron/model/war.py b/src/warchron/model/war.py index 398d742..a04a574 100644 --- a/src/warchron/model/war.py +++ b/src/warchron/model/war.py @@ -312,20 +312,16 @@ class War: return camp raise KeyError(f"Campaign {campaign_id} not found in War {self.id}") - # TODO replace multiloops by internal has_* method def get_campaign_by_round(self, round_id: str) -> Campaign | None: for camp in self.campaigns: - for rnd in camp.rounds: - if rnd.id == round_id: - return camp + if camp.has_round(round_id): + return camp return None - # TODO replace multiloops by internal has_* method def get_campaign_by_sector(self, sector_id: str) -> Campaign | None: for camp in self.campaigns: - for sect in camp.sectors.values(): - if sect.id == sector_id: - return camp + if camp.has_sector(sector_id): + return camp return None def get_campaign_by_campaign_participant( @@ -383,12 +379,10 @@ class War: name, round_id, major_id, minor_id, influence_id, mission, description ) - # TODO replace multiloops by internal has_* method def get_sector(self, sector_id: str) -> Sector: for camp in self.campaigns: - for sect in camp.sectors.values(): - if sect.id == sector_id: - return sect + if camp.has_sector(sector_id): + return camp.get_sector(sector_id) raise KeyError("Sector not found") def update_sector( @@ -439,12 +433,10 @@ class War: camp = self.get_campaign(campaign_id) return camp.add_campaign_participant(participant_id, leader, theme) - # TODO replace multiloops by internal has_* method def get_campaign_participant(self, participant_id: str) -> CampaignParticipant: for camp in self.campaigns: - for part in camp.participants.values(): - if part.id == participant_id: - return part + if camp.has_participant(participant_id): + return camp.get_campaign_participant(participant_id) raise KeyError("Participant not found") def update_campaign_participant( @@ -461,12 +453,10 @@ class War: # Round methods - # TODO replace multiloops by internal has_* method def get_round(self, round_id: str) -> Round: for camp in self.campaigns: - for rnd in camp.rounds: - if rnd.id == round_id: - return rnd + if camp.has_round(round_id): + return camp.get_round(round_id) raise KeyError("Round not found") def add_round(self, campaign_id: str) -> Round: @@ -484,7 +474,7 @@ class War: camp = self.get_campaign_by_round(round_id) if camp is not None: return camp.create_choice(round_id, participant_id) - raise KeyError("Campaign with round {round_id} doesn't exist") + raise KeyError(f"Campaign with round {round_id} doesn't exist") def update_choice( self, @@ -511,20 +501,18 @@ class War: # Battle methods - # TODO replace multiloops by internal has_* method def get_battle(self, battle_id: str) -> Battle: for camp in self.campaigns: - for rnd in camp.rounds: - for bat in rnd.battles.values(): - if bat.sector_id == battle_id: - return bat - raise KeyError("Battle not found") + battle = camp.get_battle(battle_id) + if battle is not None: + return battle + raise KeyError(f"War did not find Battle {battle_id}") def create_battle(self, round_id: str, sector_id: str) -> Battle: camp = self.get_campaign_by_round(round_id) if camp is not None: return camp.create_battle(round_id, sector_id) - raise KeyError("Campaign with round {round_id} doesn't exist") + raise KeyError(f"Campaign with round {round_id} doesn't exist") def update_battle( self, From b7a35f671218276a47bb307069d8bc3afe62d2a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20R=C3=A9aux?= Date: Fri, 20 Mar 2026 10:37:35 +0100 Subject: [PATCH 10/10] fix tie resolve group order + battle draw token icon --- src/warchron/constants.py | 6 + src/warchron/controller/round_controller.py | 7 +- src/warchron/controller/workflows.py | 56 ++++----- src/warchron/model/pairing.py | 9 +- src/warchron/model/tiebreaking.py | 126 ++++++++++++++------ 5 files changed, 129 insertions(+), 75 deletions(-) diff --git a/src/warchron/constants.py b/src/warchron/constants.py index 495565d..d2188a5 100644 --- a/src/warchron/constants.py +++ b/src/warchron/constants.py @@ -22,6 +22,7 @@ class IconName(StrEnum): PAIRING = auto() DRAW = auto() TIEBREAK = auto() + DRAWTOKEN = auto() DELETE = auto() SAVE_AS = auto() SAVE = auto() @@ -149,6 +150,11 @@ class Icons: cls.get_pixmap(IconName.TIEBREAK), cls.get_pixmap(IconName.TOKEN), ) + elif name == IconName.DRAWTOKEN: + pix = cls._compose( + cls.get_pixmap(IconName.DRAW), + cls.get_pixmap(IconName.TOKEN), + ) elif name == IconName.WINTOKEN: pix = cls._compose( cls.get_pixmap(IconName.WIN), diff --git a/src/warchron/controller/round_controller.py b/src/warchron/controller/round_controller.py index 097e0c8..393a76b 100644 --- a/src/warchron/controller/round_controller.py +++ b/src/warchron/controller/round_controller.py @@ -24,6 +24,7 @@ from warchron.model.war import War if TYPE_CHECKING: from warchron.controller.app_controller import AppController + from warchron.model.campaign import Campaign from warchron.controller.dtos import ( ParticipantOption, @@ -254,13 +255,15 @@ class RoundController: for pid in ctx.participants ] counters = [war.get_influence_tokens(pid) for pid in ctx.participants] + round: Round | None = None + campaign: Campaign | None = None if ctx.context_type == ContextType.BATTLE: - # context_id = battle.sector_id + # context_id corresponds to battle.sector_id campaign = war.get_campaign_by_sector(ctx.context_id) if campaign: round = campaign.get_round_by_battle(ctx.context_id) if ctx.context_type == ContextType.CHOICE: - # context_id = round.id + # context_id corresponds to round.id campaign = war.get_campaign_by_round(ctx.context_id) if campaign: round = war.get_round(ctx.context_id) diff --git a/src/warchron/controller/workflows.py b/src/warchron/controller/workflows.py index 37a692f..ea0a4b3 100644 --- a/src/warchron/controller/workflows.py +++ b/src/warchron/controller/workflows.py @@ -1,5 +1,4 @@ from typing import TYPE_CHECKING -from uuid import uuid4 if TYPE_CHECKING: from warchron.controller.app_controller import AppController @@ -23,13 +22,12 @@ class RoundClosureWorkflow(Workflow): Closer.check_round_closable(round) ties = TieBreaker.find_battle_ties(war, round.id) while ties: - bids_map = self.app.rounds.resolve_ties(war, ties) for tie in ties: - bids = bids_map[tie.key()] - tie_id = TieBreaker.find_active_tie_id(war, tie) or str(uuid4()) - # TODO finish tiebreak by group (like choice) not by turn - TieBreaker.apply_bids(war, tie, tie_id, bids) - TieBreaker.resolve_tie_state(war, tie, tie_id, bids) + TieBreaker.resolve_group( + war, + tie, + self.app.rounds.resolve_ties, + ) ties = TieBreaker.find_battle_ties(war, round.id) for battle in round.battles.values(): Closer.apply_battle_outcomes(war, campaign, battle) @@ -42,13 +40,12 @@ class CampaignClosureWorkflow(Workflow): Closer.check_campaign_closable(campaign) ties = TieBreaker.find_campaign_ties(war, campaign.id) while ties: - bids_map = self.app.campaigns.resolve_ties(war, ties) for tie in ties: - bids = bids_map[tie.key()] - tie_id = TieBreaker.find_active_tie_id(war, tie) or str(uuid4()) - # TODO finish tiebreak by group (like choice) not by turn - TieBreaker.apply_bids(war, tie, tie_id, bids) - TieBreaker.resolve_tie_state(war, tie, tie_id, bids) + TieBreaker.resolve_group( + war, + tie, + self.app.rounds.resolve_ties, + ) ties = TieBreaker.find_campaign_ties(war, campaign.id) for obj in war.get_objectives_used_as_maj_or_min(): objective_id = obj.id @@ -58,13 +55,12 @@ class CampaignClosureWorkflow(Workflow): objective_id, ) while ties: - bids_map = self.app.campaigns.resolve_ties(war, ties) for tie in ties: - bids = bids_map[tie.key()] - tie_id = TieBreaker.find_active_tie_id(war, tie) or str(uuid4()) - # TODO finish tiebreak by group (like choice) not by turn - TieBreaker.apply_bids(war, tie, tie_id, bids) - TieBreaker.resolve_tie_state(war, tie, tie_id, bids) + TieBreaker.resolve_group( + war, + tie, + self.app.rounds.resolve_ties, + ) ties = TieBreaker.find_campaign_objective_ties( war, campaign.id, @@ -79,13 +75,12 @@ class WarClosureWorkflow(Workflow): Closer.check_war_closable(war) ties = TieBreaker.find_war_ties(war) while ties: - bids_map = self.app.wars.resolve_ties(war, ties) for tie in ties: - bids = bids_map[tie.key()] - tie_id = TieBreaker.find_active_tie_id(war, tie) or str(uuid4()) - # TODO finish tiebreak by group (like choice) not by turn - TieBreaker.apply_bids(war, tie, tie_id, bids) - TieBreaker.resolve_tie_state(war, tie, tie_id, bids) + TieBreaker.resolve_group( + war, + tie, + self.app.rounds.resolve_ties, + ) ties = TieBreaker.find_war_ties(war) for obj in war.get_objectives_used_as_maj_or_min(): objective_id = obj.id @@ -94,13 +89,12 @@ class WarClosureWorkflow(Workflow): objective_id, ) while ties: - bids_map = self.app.wars.resolve_ties(war, ties) for tie in ties: - bids = bids_map[tie.key()] - tie_id = TieBreaker.find_active_tie_id(war, tie) or str(uuid4()) - # TODO finish tiebreak by group (like choice) not by turn - TieBreaker.apply_bids(war, tie, tie_id, bids) - TieBreaker.resolve_tie_state(war, tie, tie_id, bids) + TieBreaker.resolve_group( + war, + tie, + self.app.rounds.resolve_ties, + ) ties = TieBreaker.find_war_objective_ties( war, objective_id, diff --git a/src/warchron/model/pairing.py b/src/warchron/model/pairing.py index a186220..a8c0613 100644 --- a/src/warchron/model/pairing.py +++ b/src/warchron/model/pairing.py @@ -1,5 +1,5 @@ from __future__ import annotations -from typing import Dict, List, Callable, Tuple +from typing import Dict, List from dataclasses import dataclass from uuid import uuid4 @@ -15,15 +15,10 @@ from warchron.model.war import War from warchron.model.round import Round from warchron.model.battle import Battle from warchron.model.scoring import ScoreComputer -from warchron.model.tiebreaking import TieBreaker, TieContext +from warchron.model.tiebreaking import TieBreaker, TieContext, ResolveTiesCallback from warchron.model.war_event import TieResolved from warchron.model.scoring import ParticipantScore -ResolveTiesCallback = Callable[ - ["War", List["TieContext"]], - Dict[Tuple[str, str, int | None, str | None, str | None], Dict[str, bool]], -] - @dataclass(frozen=True, slots=True) class AllocationResult: diff --git a/src/warchron/model/tiebreaking.py b/src/warchron/model/tiebreaking.py index 8b47ec8..ed11ed0 100644 --- a/src/warchron/model/tiebreaking.py +++ b/src/warchron/model/tiebreaking.py @@ -1,6 +1,7 @@ -from typing import List, Dict, DefaultDict, Tuple +from __future__ import annotations +from typing import List, Dict, Tuple, Callable, TypeAlias from dataclasses import dataclass -from collections import defaultdict +from uuid import uuid4 from warchron.constants import ContextType, ScoreKind from warchron.model.exception import ForbiddenOperation, DomainError @@ -29,6 +30,12 @@ class TieContext: ) +ResolveTiesCallback: TypeAlias = Callable[ + [War, List[TieContext]], + Dict[Tuple[str, str, int | None, str | None, str | None], Dict[str, bool]], +] + + class TieBreaker: @staticmethod @@ -92,14 +99,22 @@ class TieBreaker: @staticmethod def find_campaign_ties(war: War, campaign_id: str) -> List[TieContext]: + from warchron.model.checking import ResultChecker + scores = ScoreComputer.compute_scores(war, ContextType.CAMPAIGN, campaign_id) - buckets: DefaultDict[int, List[str]] = defaultdict(list) - for pid, score in scores.items(): - buckets[score.victory_points].append(pid) + ranking = ResultChecker.get_effective_ranking( + war, + ContextType.CAMPAIGN, + campaign_id, + ScoreKind.VP, + scores, + lambda s: s.victory_points, + ) 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 context: TieContext = TieContext( ContextType.CAMPAIGN, campaign_id, @@ -110,13 +125,13 @@ class TieBreaker: if TieBreaker.is_tie_resolved(war, context): continue tie_id = TieBreaker.find_active_tie_id(war, context) - if not TieBreaker.can_tie_be_resolved(war, context, participants): + if not TieBreaker.can_tie_be_resolved(war, context, group): war.events.append( TieResolved( participant_id=None, context_type=ContextType.CAMPAIGN, context_id=campaign_id, - participants=participants, + participants=group, tie_id=tie_id, score_value=score_value, ) @@ -126,7 +141,7 @@ class TieBreaker: TieContext( context_type=ContextType.CAMPAIGN, context_id=campaign_id, - participants=participants, + participants=group, score_value=score_value, score_kind=ScoreKind.VP, ) @@ -137,20 +152,32 @@ class TieBreaker: def find_campaign_objective_ties( war: War, campaign_id: str, objective_id: str ) -> List[TieContext]: + from warchron.model.checking import ResultChecker + scores = ScoreComputer.compute_scores( war, ContextType.CAMPAIGN, campaign_id, ) - buckets: DefaultDict[int, List[str]] = defaultdict(list) - for pid, score in scores.items(): - np_value = score.narrative_points.get(objective_id, 0) - buckets[np_value].append(pid) + + def value_getter(score: ParticipantScore) -> int: + return score.narrative_points.get(objective_id, 0) + + ranking = ResultChecker.get_effective_ranking( + war, + ContextType.CAMPAIGN, + campaign_id, + ScoreKind.NP, + scores, + value_getter, + objective_id, + ) ties: List[TieContext] = [] context_id = campaign_id - for np_value, participants in buckets.items(): - if len(participants) <= 1: + for _, group, _ in ranking: + if len(group) <= 1: continue + np_value = value_getter(scores[group[0]]) context: TieContext = TieContext( ContextType.CAMPAIGN, campaign_id, @@ -165,14 +192,14 @@ class TieBreaker: if not TieBreaker.can_tie_be_resolved( war, context, - participants, + group, ): war.events.append( TieResolved( participant_id=None, context_type=ContextType.CAMPAIGN, context_id=context_id, - participants=participants, + participants=group, tie_id=tie_id, score_value=np_value, objective_id=objective_id, @@ -183,7 +210,7 @@ class TieBreaker: TieContext( context_type=ContextType.CAMPAIGN, context_id=context_id, - participants=participants, + participants=group, score_value=np_value, score_kind=ScoreKind.NP, objective_id=objective_id, @@ -307,6 +334,51 @@ class TieBreaker: ) return ties + @staticmethod + def resolve_group( + war: War, + context: TieContext, + resolve_ties_callback: ResolveTiesCallback, + ) -> None: + tie_id = TieBreaker.find_active_tie_id(war, context) or str(uuid4()) + while not TieBreaker.is_tie_resolved(war, context): + active = TieBreaker.get_active_participants( + war, + context, + context.participants, + ) + current_context = TieContext( + context_type=context.context_type, + context_id=context.context_id, + participants=active, + score_value=context.score_value, + score_kind=context.score_kind, + objective_id=context.objective_id, + sector_id=context.sector_id, + ) + if not TieBreaker.can_tie_be_resolved( + war, + context, + current_context.participants, + ): + war.events.append( + TieResolved( + None, + context.context_type, + context.context_id, + participants=context.participants, + tie_id=tie_id, + score_value=context.score_value, + objective_id=context.objective_id, + sector_id=context.sector_id, + ) + ) + return + bids_map = resolve_ties_callback(war, [current_context]) + bids = bids_map[current_context.key()] + TieBreaker.apply_bids(war, context, tie_id, bids) + TieBreaker.resolve_tie_state(war, context, tie_id, bids) + @staticmethod def apply_bids( war: War, @@ -447,22 +519,6 @@ class TieBreaker: active = TieBreaker.get_active_participants(war, context, participants) return any(war.get_influence_tokens(pid) > 0 for pid in active) - @staticmethod - def was_tie_broken_by_tokens( - war: War, - context: TieContext, - ) -> bool: - for ev in reversed(war.events): - if ( - isinstance(ev, TieResolved) - and ev.context_type == context.context_type - and ev.context_id == context.context_id - and ev.score_value == context.score_value - and ev.objective_id == context.objective_id - ): - return ev.participant_id is not None - return False - @staticmethod def is_tie_resolved(war: War, context: TieContext) -> bool: for ev in war.events: