From 7c9c9418640040fd4988aa42408c393b62c02e75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20R=C3=A9aux?= Date: Thu, 19 Feb 2026 14:17:42 +0100 Subject: [PATCH 1/5] compute campaign points from rounds --- README.md | 16 +-- .../controller/campaign_controller.py | 36 ++++-- src/warchron/controller/dtos.py | 19 ++++ src/warchron/model/battle.py | 7 +- src/warchron/model/campaign.py | 24 ++-- src/warchron/model/closure_service.py | 3 +- src/warchron/model/score_service.py | 106 ++++++++++++------ src/warchron/model/war.py | 9 ++ src/warchron/view/view.py | 20 +++- 9 files changed, 165 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index 79a292d..6b8c7e7 100644 --- a/README.md +++ b/README.md @@ -7,16 +7,12 @@ A simple local app to track players' campaigns for tabletop wargames. ### Main logic Manage a list of players to sign them up to be selectable for war(s) and campaign(s). -A "war" year contains several "campaign" events which contain several "battle" games organised in successive rounds. -Battle results determine campaign score which determines the war score. -Wars are independent. - -### Design notes - -* Players are global identities -* Influence tokens are scoped to a war -* Campaign order enables historical tie-breakers -* Effects are generic → future-proof +A war year offers various objectives along several campaigns. Wars are independent. +A campaign event presents customisable sectors to fight on during battle rounds. Campaigns are successive and are used for historical tie-breaker. +A round includes battles to combine all participants according to their choice. Rounds are successive and are used for participants pairing in different priority modes. +Winning battle grants victory points, narrative points (optional) and influence token (optional). +Round results determine campaign score, which determines the war score, in different counting modes. +Victory points determine the winner, narrative points grant scenario award(s) and influence tokens decide tie-breaks. ## Installation diff --git a/src/warchron/controller/campaign_controller.py b/src/warchron/controller/campaign_controller.py index 022fd54..3ae38cb 100644 --- a/src/warchron/controller/campaign_controller.py +++ b/src/warchron/controller/campaign_controller.py @@ -2,21 +2,22 @@ from typing import List, TYPE_CHECKING from PyQt6.QtWidgets import QMessageBox, QDialog -from warchron.constants import RefreshScope +from warchron.constants import RefreshScope, ContextType if TYPE_CHECKING: from warchron.controller.app_controller import AppController from warchron.controller.dtos import ( ParticipantOption, ObjectiveDTO, - CampaignParticipantDTO, SectorDTO, RoundDTO, + CampaignParticipantScoreDTO, ) from warchron.model.campaign import Campaign from warchron.model.campaign_participant import CampaignParticipant from warchron.model.sector import Sector from warchron.model.closure_service import ClosureService +from warchron.model.score_service import ScoreService from warchron.view.campaign_dialog import CampaignDialog from warchron.view.campaign_participant_dialog import CampaignParticipantDialog from warchron.view.sector_dialog import SectorDialog @@ -45,17 +46,28 @@ class CampaignController: for sect in sectors ] self.app.view.display_campaign_sectors(sectors_for_display) - camp_parts = camp.get_all_campaign_participants() - participants_for_display: List[CampaignParticipantDTO] = [ - CampaignParticipantDTO( - id=p.id, - player_name=self.app.model.get_participant_name(p.war_participant_id), - leader=p.leader or "", - theme=p.theme or "", + scores = ScoreService.compute_scores(war, ContextType.CAMPAIGN, campaign_id) + rows: List[CampaignParticipantScoreDTO] = [] + for camp_part in camp.get_all_campaign_participants(): + war_part_id = camp_part.war_participant_id + war_part = war.get_war_participant(war_part_id) + player_name = self.app.model.get_player_name(war_part.player_id) + score = scores[war_part_id] + rows.append( + CampaignParticipantScoreDTO( + campaign_participant_id=camp_part.id, + war_participant_id=war_part_id, + player_name=player_name, + leader=camp_part.leader or "", + theme=camp_part.theme or "", + victory_points=score.victory_points, + narrative_points=dict(score.narrative_points), + ) ) - for p in camp_parts + objectives = [ + ObjectiveDTO(o.id, o.name, o.description) for o in war.get_all_objectives() ] - self.app.view.display_campaign_participants(participants_for_display) + self.app.view.display_campaign_participants(rows, objectives) self.app.view.endCampaignBtn.setEnabled(not camp.is_over) def _validate_campaign_inputs(self, name: str, month: int) -> bool: @@ -189,7 +201,7 @@ class CampaignController: self.app.view, "Invalid name", "Sector name cannot be empty." ) return False - # allow same objectives in different fields? + # TODO allow same objectives in different fields? return True def create_sector(self) -> Sector | None: diff --git a/src/warchron/controller/dtos.py b/src/warchron/controller/dtos.py index 5222b03..bbfc45b 100644 --- a/src/warchron/controller/dtos.py +++ b/src/warchron/controller/dtos.py @@ -114,3 +114,22 @@ class TieContext: context_type: ContextType context_id: str participants: List[str] # war_participant_ids + + +@dataclass(frozen=True, slots=True) +class ParticipantScoreDTO: + participant_id: str + player_name: str + victory_points: int + narrative_points: dict[str, int] + + +@dataclass(frozen=True, slots=True) +class CampaignParticipantScoreDTO: + campaign_participant_id: str + war_participant_id: str + player_name: str + leader: str + theme: str + victory_points: int + narrative_points: dict[str, int] diff --git a/src/warchron/model/battle.py b/src/warchron/model/battle.py index 1af9b2b..3ff23a7 100644 --- a/src/warchron/model/battle.py +++ b/src/warchron/model/battle.py @@ -43,12 +43,13 @@ class Battle: def is_draw(self) -> bool: if self.winner_id is not None: return False - # Case 1: score entered → interpreted as unresolved outcome if self.score and self.score.strip(): return True - # Case 2: explicit draw mention if self.victory_condition: - if "draw" in self.victory_condition.casefold(): + if any( + keyword in (self.victory_condition or "").casefold() + for keyword in ["draw", "tie", "square"] + ): return True return False diff --git a/src/warchron/model/campaign.py b/src/warchron/model/campaign.py index 2b8148a..e2fce92 100644 --- a/src/warchron/model/campaign.py +++ b/src/warchron/model/campaign.py @@ -284,6 +284,22 @@ class Campaign: return rnd raise KeyError(f"Round {round_id} not found") + def get_round_index(self, round_id: str | None) -> int | None: + if round_id is None: + return None + for index, rnd in enumerate(self.rounds, start=1): + if rnd.id == round_id: + 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 + raise KeyError(f"Battle {sector_id} not found in any Round") + def get_all_rounds(self) -> List[Round]: return list(self.rounds) @@ -309,14 +325,6 @@ class Campaign: if rnd: self.rounds.remove(rnd) - def get_round_index(self, round_id: str | None) -> int | None: - if round_id is None: - return None - for index, rnd in enumerate(self.rounds, start=1): - if rnd.id == round_id: - return index - raise KeyError("Round not found in campaign") - # Choice methods def create_choice(self, round_id: str, participant_id: str) -> Choice: diff --git a/src/warchron/model/closure_service.py b/src/warchron/model/closure_service.py index fb31372..5e6446e 100644 --- a/src/warchron/model/closure_service.py +++ b/src/warchron/model/closure_service.py @@ -32,10 +32,9 @@ class ClosureService: ) if already_granted: return + base_winner = None if battle.winner_id is not None: base_winner = campaign.participants[battle.winner_id].war_participant_id - else: - base_winner = None effective_winner = TieResolver.get_effective_winner_id( war, ContextType.BATTLE, diff --git a/src/warchron/model/score_service.py b/src/warchron/model/score_service.py index 298139f..9274f40 100644 --- a/src/warchron/model/score_service.py +++ b/src/warchron/model/score_service.py @@ -1,45 +1,77 @@ -from typing import Dict, TYPE_CHECKING +from typing import Dict, Iterator +from dataclasses import dataclass, field -if TYPE_CHECKING: - from warchron.model.war import War +from warchron.constants import ContextType +from warchron.model.tie_manager import TieResolver +from warchron.model.war import War +from warchron.model.battle import Battle + + +@dataclass(slots=True) +class ParticipantScore: + victory_points: int = 0 + narrative_points: Dict[str, int] = field(default_factory=dict) class ScoreService: @staticmethod - def compute_victory_points_for_participant(war: "War", participant_id: str) -> int: - total = 0 - for campaign in war.campaigns: - for round_ in campaign.rounds: - for battle in round_.battles.values(): - if battle.winner_id == participant_id: - sector = campaign.sectors[battle.sector_id] - if sector.major_objective_id: - total += war.major_value - if sector.minor_objective_id: - total += war.minor_value - return total + def _get_battles_for_context( + war: War, context_type: ContextType, context_id: str + ) -> Iterator[Battle]: + if context_type == ContextType.WAR: + for camp in war.campaigns: + for rnd in camp.rounds: + if not rnd.is_over: + continue + yield from rnd.battles.values() + + elif context_type == ContextType.CAMPAIGN: + campaign = war.get_campaign(context_id) + for rnd in campaign.rounds: + if not rnd.is_over: + continue + yield from rnd.battles.values() + + elif context_type == ContextType.BATTLE: + battle = war.get_battle(context_id) + campaign = war.get_campaign_by_sector(battle.sector_id) + rnd = campaign.get_round_by_battle(context_id) + if rnd and rnd.is_over: + yield battle @staticmethod - def compute_narrative_points_for_participant( - war: "War", participant_id: str - ) -> Dict[str, int]: - totals: Dict[str, int] = {} - for obj_id in war.objectives: - totals[obj_id] = 0 - for campaign in war.campaigns: - for round_ in campaign.rounds: - for battle in round_.battles.values(): - if battle.winner_id == participant_id: - sector = campaign.sectors[battle.sector_id] - if sector.major_objective_id: - totals[sector.major_objective_id] += war.major_value - if sector.minor_objective_id: - totals[sector.minor_objective_id] += war.minor_value - return totals - - # def compute_round_results(round) - - # def compute_campaign_winner(campaign) - - # def compute_war_winner(war) + def compute_scores( + war: War, context_type: ContextType, context_id: str + ) -> Dict[str, ParticipantScore]: + scores = { + pid: ParticipantScore( + narrative_points={obj_id: 0 for obj_id in war.objectives} + ) + for pid in war.participants + } + battles = ScoreService._get_battles_for_context(war, context_type, context_id) + for battle in battles: + base_winner = None + if battle.winner_id is not None: + campaign = war.get_campaign_by_campaign_participant(battle.winner_id) + if campaign is not None: + base_winner = campaign.participants[ + battle.winner_id + ].war_participant_id + winner = TieResolver.get_effective_winner_id( + war, ContextType.BATTLE, battle.sector_id, base_winner + ) + if winner is None: + continue + scores[winner].victory_points += 1 + sector = war.get_sector(battle.sector_id) + if sector.major_objective_id: + scores[winner].narrative_points[ + sector.major_objective_id + ] += war.major_value + if sector.minor_objective_id: + scores[winner].narrative_points[ + sector.minor_objective_id + ] += war.minor_value + return scores diff --git a/src/warchron/model/war.py b/src/warchron/model/war.py index 93e9cc5..9091e61 100644 --- a/src/warchron/model/war.py +++ b/src/warchron/model/war.py @@ -413,6 +413,15 @@ 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("Round not found") + def create_battle(self, round_id: str, sector_id: str) -> Battle: camp = self.get_campaign_by_round(round_id) if camp is not None: diff --git a/src/warchron/view/view.py b/src/warchron/view/view.py index 5082df5..aae2527 100644 --- a/src/warchron/view/view.py +++ b/src/warchron/view/view.py @@ -15,10 +15,10 @@ from warchron.controller.dtos import ( WarDTO, WarParticipantDTO, ObjectiveDTO, - CampaignParticipantDTO, SectorDTO, ChoiceDTO, BattleDTO, + CampaignParticipantScoreDTO, ) from warchron.view.helpers import ( format_campaign_label, @@ -464,19 +464,33 @@ class View(QtWidgets.QMainWindow, Ui_MainWindow): table.resizeColumnsToContents() def display_campaign_participants( - self, participants: List[CampaignParticipantDTO] + self, + participants: List[CampaignParticipantScoreDTO], + objectives: List[ObjectiveDTO], ) -> None: table = self.campaignParticipantsTable table.clearContents() + base_cols = ["Player", "Leader", "Theme", "Victory"] + headers = base_cols + [obj.name for obj in objectives] + table.setColumnCount(len(headers)) + table.setHorizontalHeaderLabels(headers) table.setRowCount(len(participants)) for row, part in enumerate(participants): name_item = QtWidgets.QTableWidgetItem(part.player_name) lead_item = QtWidgets.QTableWidgetItem(part.leader) theme_item = QtWidgets.QTableWidgetItem(part.theme) - name_item.setData(Qt.ItemDataRole.UserRole, part.id) + VP_item = QtWidgets.QTableWidgetItem(str(part.victory_points)) + name_item.setData(Qt.ItemDataRole.UserRole, part.campaign_participant_id) table.setItem(row, 0, name_item) table.setItem(row, 1, lead_item) table.setItem(row, 2, theme_item) + table.setItem(row, 3, VP_item) + col = 4 + for obj in objectives: + value = part.narrative_points.get(obj.id, 0) + NP_item = QtWidgets.QTableWidgetItem(str(value)) + table.setItem(row, col, NP_item) + col += 1 table.resizeColumnsToContents() # Round page From 60d8e6ca155f9bd472df375d309cfa3016cef70d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20R=C3=A9aux?= Date: Fri, 20 Feb 2026 11:01:25 +0100 Subject: [PATCH 2/5] detect campaign tie --- .../controller/campaign_controller.py | 61 ++++++++----- src/warchron/controller/closure_workflow.py | 88 ++++++++++++------- src/warchron/controller/dtos.py | 9 -- src/warchron/controller/round_controller.py | 6 +- src/warchron/model/closure_service.py | 33 +++---- src/warchron/model/result_checker.py | 24 +++++ src/warchron/model/score_service.py | 4 +- src/warchron/model/tie_manager.py | 84 ++++++++++++------ src/warchron/model/war.py | 10 ++- 9 files changed, 203 insertions(+), 116 deletions(-) create mode 100644 src/warchron/model/result_checker.py diff --git a/src/warchron/controller/campaign_controller.py b/src/warchron/controller/campaign_controller.py index 3ae38cb..4c72427 100644 --- a/src/warchron/controller/campaign_controller.py +++ b/src/warchron/controller/campaign_controller.py @@ -1,8 +1,8 @@ -from typing import List, TYPE_CHECKING +from typing import List, Dict, TYPE_CHECKING from PyQt6.QtWidgets import QMessageBox, QDialog -from warchron.constants import RefreshScope, ContextType +from warchron.constants import RefreshScope, ContextType, ItemType if TYPE_CHECKING: from warchron.controller.app_controller import AppController @@ -13,14 +13,18 @@ from warchron.controller.dtos import ( RoundDTO, CampaignParticipantScoreDTO, ) +from warchron.model.exception import ForbiddenOperation, DomainError +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.closure_service import ClosureService +from warchron.model.tie_manager import TieContext from warchron.model.score_service import ScoreService from warchron.view.campaign_dialog import CampaignDialog from warchron.view.campaign_participant_dialog import CampaignParticipantDialog from warchron.view.sector_dialog import SectorDialog +from warchron.controller.closure_workflow import CampaignClosureWorkflow +from warchron.view.tie_dialog import TieDialog class CampaignController: @@ -101,10 +105,6 @@ class CampaignController: return self.app.model.add_campaign( self.app.navigation.selected_war_id, name, month ) - # self.app.is_dirty = True - # self.app.navigation.refresh_and_select( - # RefreshScope.WARS_TREE, item_type=ItemType.CAMPAIGN, item_id=camp.id - # ) def edit_campaign(self, campaign_id: str) -> None: camp = self.app.model.get_campaign(campaign_id) @@ -123,25 +123,46 @@ class CampaignController: if not campaign_id: return camp = self.app.model.get_campaign(campaign_id) - if camp.is_over: - return + war = self.app.model.get_war_by_campaign(campaign_id) + workflow = CampaignClosureWorkflow(self.app) try: - ties = ClosureService.close_campaign(camp) - except RuntimeError as e: - QMessageBox.warning(self.app.view, "Cannot close campaign", str(e)) - return - if ties: - QMessageBox.information( + workflow.start(war, camp) + except DomainError as e: + QMessageBox.warning( self.app.view, - "Tie detected", - "Campaign has unresolved ties.", + "Deletion forbidden", + str(e), ) - return self.app.is_dirty = True self.app.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) - self.app.navigation.refresh(RefreshScope.WARS_TREE) + self.app.navigation.refresh_and_select( + RefreshScope.WARS_TREE, item_type=ItemType.CAMPAIGN, item_id=campaign_id + ) - # Campaign participant methods + def resolve_ties( + self, war: War, contexts: List[TieContext] + ) -> Dict[str, Dict[str, bool]]: + bids_map = {} + for ctx in contexts: + players = [ + ParticipantOption( + id=pid, + name=self.app.model.get_participant_name(pid), + ) + for pid in ctx.participants + ] + counters = [war.get_influence_tokens(pid) for pid in ctx.participants] + dialog = TieDialog( + parent=self.app.view, + players=players, + counters=counters, + context_type=ContextType.CAMPAIGN, + context_id=ctx.context_id, + ) + if not dialog.exec(): + raise ForbiddenOperation("Tie resolution cancelled") + bids_map[ctx.context_id] = dialog.get_bids() + return bids_map def create_campaign_participant(self) -> CampaignParticipant | None: if not self.app.navigation.selected_campaign_id: diff --git a/src/warchron/controller/closure_workflow.py b/src/warchron/controller/closure_workflow.py index 71a1e3d..5699d67 100644 --- a/src/warchron/controller/closure_workflow.py +++ b/src/warchron/controller/closure_workflow.py @@ -3,16 +3,12 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: from warchron.controller.app_controller import AppController -from warchron.constants import ContextType -from warchron.model.exception import ForbiddenOperation from warchron.model.war_event import TieResolved from warchron.model.war import War from warchron.model.campaign import Campaign -from warchron.model.battle import Battle from warchron.model.round import Round from warchron.model.closure_service import ClosureService from warchron.model.tie_manager import TieResolver -from warchron.controller.dtos import TieContext class ClosureWorkflow: @@ -25,53 +21,77 @@ class RoundClosureWorkflow(ClosureWorkflow): def start(self, war: War, campaign: Campaign, round: Round) -> None: ClosureService.check_round_closable(round) - ties = TieResolver.find_round_ties(round, war) + ties = TieResolver.find_battle_ties(war, round.id) while ties: - contexts = [ - RoundClosureWorkflow.build_battle_context(campaign, b) for b in ties - ] resolvable = [] - for ctx in contexts: - if TieResolver.can_tie_be_resolved(war, ctx.participants): - resolvable.append(ctx) + for tie in ties: + if TieResolver.can_tie_be_resolved(war, tie.participants): + resolvable.append(tie) else: war.events.append( TieResolved( - participant_id=None, - context_type=ctx.context_type, - context_id=ctx.context_id, + participant_id=None, # draw confirmed + context_type=tie.context_type, + context_id=tie.context_id, ) ) if not resolvable: break - bids_map = self.app.rounds.resolve_ties(war, contexts) - for ctx in contexts: - bids = bids_map[ctx.context_id] + bids_map = self.app.rounds.resolve_ties(war, resolvable) + for tie in resolvable: + bids = bids_map[tie.context_id] TieResolver.apply_bids( war, - ctx.context_type, - ctx.context_id, + tie.context_type, + tie.context_id, bids, ) TieResolver.try_tie_break( war, - ctx.context_type, - ctx.context_id, - ctx.participants, + tie.context_type, + tie.context_id, + tie.participants, ) - ties = TieResolver.find_round_ties(round, war) + ties = TieResolver.find_battle_ties(war, round.id) for battle in round.battles.values(): ClosureService.apply_battle_outcomes(war, campaign, battle) ClosureService.finalize_round(round) - @staticmethod - def build_battle_context(campaign: Campaign, battle: Battle) -> TieContext: - if battle.player_1_id is None or battle.player_2_id is None: - raise ForbiddenOperation("Missing player(s) in this battle context.") - p1 = campaign.participants[battle.player_1_id].war_participant_id - p2 = campaign.participants[battle.player_2_id].war_participant_id - return TieContext( - context_type=ContextType.BATTLE, - context_id=battle.sector_id, - participants=[p1, p2], - ) + +class CampaignClosureWorkflow(ClosureWorkflow): + + def start(self, war: War, campaign: Campaign) -> None: + ClosureService.check_campaign_closable(campaign) + ties = TieResolver.find_campaign_ties(war, campaign.id) + while ties: + resolvable = [] + for tie in ties: + if TieResolver.can_tie_be_resolved(war, tie.participants): + resolvable.append(tie) + else: + war.events.append( + TieResolved( + participant_id=None, + context_type=tie.context_type, + context_id=tie.context_id, + ) + ) + if not resolvable: + break + bids_map = self.app.campaigns.resolve_ties(war, resolvable) + for tie in resolvable: + bids = bids_map[tie.context_id] + TieResolver.apply_bids( + war, + tie.context_type, + tie.context_id, + bids, + ) + TieResolver.try_tie_break( + war, + tie.context_type, + tie.context_id, + tie.participants, + ) + ties = TieResolver.find_campaign_ties(war, campaign.id) + ClosureService.finalize_campaign(campaign) diff --git a/src/warchron/controller/dtos.py b/src/warchron/controller/dtos.py index bbfc45b..7d52aaf 100644 --- a/src/warchron/controller/dtos.py +++ b/src/warchron/controller/dtos.py @@ -3,8 +3,6 @@ from dataclasses import dataclass from PyQt6.QtGui import QIcon -from warchron.constants import ContextType - @dataclass(frozen=True) class ParticipantOption: @@ -109,13 +107,6 @@ class BattleDTO: player2_tooltip: str | None = None -@dataclass -class TieContext: - context_type: ContextType - context_id: str - participants: List[str] # war_participant_ids - - @dataclass(frozen=True, slots=True) class ParticipantScoreDTO: participant_id: str diff --git a/src/warchron/controller/round_controller.py b/src/warchron/controller/round_controller.py index d0c8601..8fdc4c9 100644 --- a/src/warchron/controller/round_controller.py +++ b/src/warchron/controller/round_controller.py @@ -6,7 +6,8 @@ from PyQt6.QtGui import QIcon from warchron.constants import ItemType, RefreshScope, Icons, IconName, ContextType from warchron.model.exception import ForbiddenOperation, DomainError -from warchron.model.tie_manager import TieResolver +from warchron.model.tie_manager import TieResolver, TieContext +from warchron.model.result_checker import ResultChecker from warchron.model.round import Round from warchron.model.war import War @@ -18,7 +19,6 @@ from warchron.controller.dtos import ( SectorDTO, ChoiceDTO, BattleDTO, - TieContext, ) from warchron.controller.closure_workflow import RoundClosureWorkflow from warchron.view.choice_dialog import ChoiceDialog @@ -108,7 +108,7 @@ class RoundController: if TieResolver.was_tie_broken_by_tokens( war, ContextType.BATTLE, battle.sector_id ): - effective_winner = TieResolver.get_effective_winner_id( + effective_winner = ResultChecker.get_effective_winner_id( war, ContextType.BATTLE, battle.sector_id, None ) p1_war = None diff --git a/src/warchron/model/closure_service.py b/src/warchron/model/closure_service.py index 5e6446e..93ab166 100644 --- a/src/warchron/model/closure_service.py +++ b/src/warchron/model/closure_service.py @@ -3,7 +3,7 @@ from typing import List from warchron.constants import ContextType from warchron.model.exception import ForbiddenOperation -from warchron.model.tie_manager import TieResolver +from warchron.model.result_checker import ResultChecker from warchron.model.war_event import InfluenceGained from warchron.model.war import War from warchron.model.campaign import Campaign @@ -35,7 +35,7 @@ class ClosureService: base_winner = None if battle.winner_id is not None: base_winner = campaign.participants[battle.winner_id].war_participant_id - effective_winner = TieResolver.get_effective_winner_id( + effective_winner = ResultChecker.get_effective_winner_id( war, ContextType.BATTLE, battle.sector_id, @@ -60,28 +60,17 @@ class ClosureService: # Campaign methods @staticmethod - def close_campaign(campaign: Campaign) -> List[str]: + def check_campaign_closable(campaign: Campaign) -> None: + if campaign.is_over: + raise ForbiddenOperation("Campaign already closed") if not campaign.all_rounds_finished(): - raise RuntimeError("All rounds must be finished to close their campaign") - ties: List[str] = [] - # for round in campaign.rounds: - # # compute score - # # if participants have same score - # ties.append( - # ResolutionContext( - # context_type=ContextType.CAMPAIGN, - # context_id=campaign.id, - # participant_ids=[ - # # TODO ref to War.participants at some point - # campaign.participants[campaign_participant_id], - # campaign.participants[campaign_participant_id], - # ], - # ) - # ) - if ties: - return ties + raise ForbiddenOperation( + "All rounds must be closed to close their campaign" + ) + + @staticmethod + def finalize_campaign(campaign: Campaign) -> None: campaign.is_over = True - return [] # War methods diff --git a/src/warchron/model/result_checker.py b/src/warchron/model/result_checker.py new file mode 100644 index 0000000..de07440 --- /dev/null +++ b/src/warchron/model/result_checker.py @@ -0,0 +1,24 @@ +from warchron.constants import ContextType +from warchron.model.war import War +from warchron.model.war_event import TieResolved + + +class ResultChecker: + @staticmethod + def get_effective_winner_id( + war: War, + context_type: ContextType, + context_id: str, + base_winner_id: str | None, + ) -> str | None: + if base_winner_id is not None: + return base_winner_id + for ev in reversed(war.events): + if ( + isinstance(ev, TieResolved) + and ev.context_type == context_type + and ev.context_id == context_id + ): + return ev.participant_id # None if confirmed draw + + return None diff --git a/src/warchron/model/score_service.py b/src/warchron/model/score_service.py index 9274f40..16da0ab 100644 --- a/src/warchron/model/score_service.py +++ b/src/warchron/model/score_service.py @@ -1,8 +1,8 @@ from typing import Dict, Iterator from dataclasses import dataclass, field +from warchron.model.result_checker import ResultChecker from warchron.constants import ContextType -from warchron.model.tie_manager import TieResolver from warchron.model.war import War from warchron.model.battle import Battle @@ -59,7 +59,7 @@ class ScoreService: base_winner = campaign.participants[ battle.winner_id ].war_participant_id - winner = TieResolver.get_effective_winner_id( + winner = ResultChecker.get_effective_winner_id( war, ContextType.BATTLE, battle.sector_id, base_winner ) if winner is None: diff --git a/src/warchron/model/tie_manager.py b/src/warchron/model/tie_manager.py index 54cef74..36c08ab 100644 --- a/src/warchron/model/tie_manager.py +++ b/src/warchron/model/tie_manager.py @@ -1,17 +1,27 @@ -from typing import List, Dict +from typing import List, Dict, DefaultDict +from dataclasses import dataclass +from collections import defaultdict from warchron.constants import ContextType from warchron.model.exception import ForbiddenOperation from warchron.model.war import War -from warchron.model.round import Round -from warchron.model.battle import Battle from warchron.model.war_event import InfluenceSpent, TieResolved +from warchron.model.score_service import ScoreService + + +@dataclass +class TieContext: + context_type: ContextType + context_id: str + participants: List[str] # war_participant_ids class TieResolver: @staticmethod - def find_round_ties(round: Round, war: War) -> List[Battle]: + def find_battle_ties(war: War, round_id: str) -> List[TieContext]: + round = war.get_round(round_id) + campaign = war.get_campaign_by_round(round_id) ties = [] for battle in round.battles.values(): if not battle.is_draw(): @@ -22,10 +32,53 @@ class TieResolver: and e.context_id == battle.sector_id for e in war.events ) - if not resolved: - ties.append(battle) + if resolved: + continue + if campaign is None: + raise RuntimeError("No campaign for this battle tie") + if battle.player_1_id is None or battle.player_2_id is None: + raise RuntimeError("Missing player(s) in this battle context.") + p1 = campaign.participants[battle.player_1_id].war_participant_id + p2 = campaign.participants[battle.player_2_id].war_participant_id + ties.append( + TieContext( + context_type=ContextType.BATTLE, + context_id=battle.sector_id, + participants=[p1, p2], + ) + ) return ties + @staticmethod + def find_campaign_ties(war: War, campaign_id: str) -> List[TieContext]: + resolved = any( + isinstance(e, TieResolved) + and e.context_type == ContextType.CAMPAIGN + and e.context_id == campaign_id + for e in war.events + ) + if resolved: + return [] + scores = ScoreService.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) + ties = [] + for participants in buckets.values(): + if len(participants) > 1: + ties.append( + TieContext( + context_type=ContextType.CAMPAIGN, + context_id=campaign_id, + participants=participants, + ) + ) + return ties + + @staticmethod + def find_war_ties(war: War) -> List[TieContext]: + return [] # TODO + @staticmethod def apply_bids( war: War, @@ -88,25 +141,6 @@ class TieResolver: def can_tie_be_resolved(war: War, participants: List[str]) -> bool: return any(war.get_influence_tokens(pid) > 0 for pid in participants) - @staticmethod - def get_effective_winner_id( - war: War, - context_type: ContextType, - context_id: str, - base_winner_id: str | None, - ) -> str | None: - if base_winner_id is not None: - return base_winner_id - for ev in reversed(war.events): - if ( - isinstance(ev, TieResolved) - and ev.context_type == context_type - and ev.context_id == context_id - ): - return ev.participant_id # None if confirmed draw - - return None - @staticmethod def was_tie_broken_by_tokens( war: War, diff --git a/src/warchron/model/war.py b/src/warchron/model/war.py index 9091e61..8681d12 100644 --- a/src/warchron/model/war.py +++ b/src/warchron/model/war.py @@ -371,6 +371,14 @@ 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 + raise KeyError("Round not found") + def add_round(self, campaign_id: str) -> Round: camp = self.get_campaign(campaign_id) return camp.add_round() @@ -420,7 +428,7 @@ class War: for bat in rnd.battles.values(): if bat.sector_id == battle_id: return bat - raise KeyError("Round not found") + raise KeyError("Battle not found") def create_battle(self, round_id: str, sector_id: str) -> Battle: camp = self.get_campaign_by_round(round_id) From f339498f97df89be76ce6f30be2646e60a3370a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20R=C3=A9aux?= Date: Fri, 20 Feb 2026 23:44:22 +0100 Subject: [PATCH 3/5] fix round tie loop + improve tie ranking --- src/warchron/controller/app_controller.py | 2 + src/warchron/controller/closure_workflow.py | 42 +------- src/warchron/model/campaign.py | 1 + src/warchron/model/closure_service.py | 6 +- src/warchron/model/tie_manager.py | 113 ++++++++++++-------- src/warchron/model/war_event.py | 45 ++++---- 6 files changed, 99 insertions(+), 110 deletions(-) diff --git a/src/warchron/controller/app_controller.py b/src/warchron/controller/app_controller.py index 7ac6d7c..394d04b 100644 --- a/src/warchron/controller/app_controller.py +++ b/src/warchron/controller/app_controller.py @@ -96,6 +96,7 @@ class AppController: self.navigation.refresh_players_view() self.navigation.refresh_wars_view() self.update_window_title() + # TODO refresh details view if wars tab selected def open_file(self) -> None: if self.is_dirty: @@ -116,6 +117,7 @@ class AppController: self.navigation.refresh_players_view() self.navigation.refresh_wars_view() self.update_window_title() + # TODO refresh details view if wars tab selected def save(self) -> None: if not self.current_file: diff --git a/src/warchron/controller/closure_workflow.py b/src/warchron/controller/closure_workflow.py index 5699d67..16e0de3 100644 --- a/src/warchron/controller/closure_workflow.py +++ b/src/warchron/controller/closure_workflow.py @@ -3,7 +3,6 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: from warchron.controller.app_controller import AppController -from warchron.model.war_event import TieResolved from warchron.model.war import War from warchron.model.campaign import Campaign from warchron.model.round import Round @@ -23,22 +22,8 @@ class RoundClosureWorkflow(ClosureWorkflow): ClosureService.check_round_closable(round) ties = TieResolver.find_battle_ties(war, round.id) while ties: - resolvable = [] + bids_map = self.app.rounds.resolve_ties(war, ties) for tie in ties: - if TieResolver.can_tie_be_resolved(war, tie.participants): - resolvable.append(tie) - else: - war.events.append( - TieResolved( - participant_id=None, # draw confirmed - context_type=tie.context_type, - context_id=tie.context_id, - ) - ) - if not resolvable: - break - bids_map = self.app.rounds.resolve_ties(war, resolvable) - for tie in resolvable: bids = bids_map[tie.context_id] TieResolver.apply_bids( war, @@ -46,11 +31,8 @@ class RoundClosureWorkflow(ClosureWorkflow): tie.context_id, bids, ) - TieResolver.try_tie_break( - war, - tie.context_type, - tie.context_id, - tie.participants, + TieResolver.resolve_tie_state( + war, tie.context_type, tie.context_id, tie.participants, bids ) ties = TieResolver.find_battle_ties(war, round.id) for battle in round.battles.values(): @@ -64,22 +46,8 @@ class CampaignClosureWorkflow(ClosureWorkflow): ClosureService.check_campaign_closable(campaign) ties = TieResolver.find_campaign_ties(war, campaign.id) while ties: - resolvable = [] + bids_map = self.app.campaigns.resolve_ties(war, ties) for tie in ties: - if TieResolver.can_tie_be_resolved(war, tie.participants): - resolvable.append(tie) - else: - war.events.append( - TieResolved( - participant_id=None, - context_type=tie.context_type, - context_id=tie.context_id, - ) - ) - if not resolvable: - break - bids_map = self.app.campaigns.resolve_ties(war, resolvable) - for tie in resolvable: bids = bids_map[tie.context_id] TieResolver.apply_bids( war, @@ -87,7 +55,7 @@ class CampaignClosureWorkflow(ClosureWorkflow): tie.context_id, bids, ) - TieResolver.try_tie_break( + TieResolver.resolve_tie_state( war, tie.context_type, tie.context_id, diff --git a/src/warchron/model/campaign.py b/src/warchron/model/campaign.py index e2fce92..eb55bd3 100644 --- a/src/warchron/model/campaign.py +++ b/src/warchron/model/campaign.py @@ -187,6 +187,7 @@ class Campaign: mission: str | None, description: str | None, ) -> None: + # TODO raise error if sector used in a closed round (potential tokens) if self.is_over: raise ForbiddenOperation("Can't update sector in a closed campaign.") sect = self.get_sector(sector_id) diff --git a/src/warchron/model/closure_service.py b/src/warchron/model/closure_service.py index 93ab166..3816e5c 100644 --- a/src/warchron/model/closure_service.py +++ b/src/warchron/model/closure_service.py @@ -27,7 +27,8 @@ class ClosureService: @staticmethod def apply_battle_outcomes(war: War, campaign: Campaign, battle: Battle) -> None: already_granted = any( - isinstance(e, InfluenceGained) and e.source == f"battle:{battle.sector_id}" + isinstance(e, InfluenceGained) + and e.context_id == f"battle:{battle.sector_id}" for e in war.events ) if already_granted: @@ -49,7 +50,8 @@ class ClosureService: InfluenceGained( participant_id=effective_winner, amount=1, - source=f"battle:{battle.sector_id}", + context_type=ContextType.BATTLE, + context_id=f"battle:{battle.sector_id}", ) ) diff --git a/src/warchron/model/tie_manager.py b/src/warchron/model/tie_manager.py index 36c08ab..f9c4054 100644 --- a/src/warchron/model/tie_manager.py +++ b/src/warchron/model/tie_manager.py @@ -16,6 +16,14 @@ class TieContext: participants: List[str] # war_participant_ids +@dataclass +class TieState: + winner: str | None + tied_players: List[str] + eliminated: List[str] + # is_final: bool + + class TieResolver: @staticmethod @@ -26,20 +34,20 @@ class TieResolver: for battle in round.battles.values(): if not battle.is_draw(): continue - resolved = any( - isinstance(e, TieResolved) - and e.context_type == ContextType.BATTLE - and e.context_id == battle.sector_id - for e in war.events - ) - if resolved: + if TieResolver.is_tie_resolved(war, ContextType.BATTLE, battle.sector_id): continue + if campaign is None: raise RuntimeError("No campaign for this battle tie") if battle.player_1_id is None or battle.player_2_id is None: raise RuntimeError("Missing player(s) in this battle context.") p1 = campaign.participants[battle.player_1_id].war_participant_id p2 = campaign.participants[battle.player_2_id].war_participant_id + if not TieResolver.can_tie_be_resolved(war, [p1, p2]): + war.events.append( + TieResolved(None, ContextType.BATTLE, battle.sector_id) + ) + continue ties.append( TieContext( context_type=ContextType.BATTLE, @@ -51,13 +59,7 @@ class TieResolver: @staticmethod def find_campaign_ties(war: War, campaign_id: str) -> List[TieContext]: - resolved = any( - isinstance(e, TieResolved) - and e.context_type == ContextType.CAMPAIGN - and e.context_id == campaign_id - for e in war.events - ) - if resolved: + if TieResolver.is_tie_resolved(war, ContextType.CAMPAIGN, campaign_id): return [] scores = ScoreService.compute_scores(war, ContextType.CAMPAIGN, campaign_id) buckets: DefaultDict[int, List[str]] = defaultdict(list) @@ -96,46 +98,54 @@ class TieResolver: participant_id=war_part_id, amount=1, context_type=context_type, + context_id=context_id, ) ) @staticmethod - def try_tie_break( + def rank_by_tokens( war: War, context_type: ContextType, context_id: str, - participants: List[str], # war_participant_ids - ) -> bool: - spent: Dict[str, int] = {} - for war_part_id in participants: - spent[war_part_id] = sum( - e.amount - for e in war.events - if isinstance(e, InfluenceSpent) - and e.participant_id == war_part_id - and e.context_type == context_type - ) - values = set(spent.values()) - if values == {0}: # no bid = confirmed draw - war.events.append( - TieResolved( - participant_id=None, - context_type=context_type, - context_id=context_id, - ) - ) - return True - if len(values) == 1: # tie again, continue - return False - winner = max(spent.items(), key=lambda item: item[1])[0] - war.events.append( - TieResolved( - participant_id=winner, - context_type=context_type, - context_id=context_id, - ) - ) - return True + participants: List[str], + ) -> List[List[str]]: + spent = {pid: 0 for pid in participants} + for ev in war.events: + if ( + isinstance(ev, InfluenceSpent) + and ev.context_type == context_type + and ev.context_id == context_id + and ev.participant_id in spent + ): + spent[ev.participant_id] += ev.amount + sorted_items = sorted(spent.items(), key=lambda x: x[1], reverse=True) + groups: List[List[str]] = [] + current_score = None + for pid, score in sorted_items: + if score != current_score: + groups.append([]) + current_score = score + groups[-1].append(pid) + return groups + + @staticmethod + def resolve_tie_state( + war: War, + context_type: ContextType, + context_id: str, + participants: List[str], + bids: dict[str, bool] | None = None, + ) -> None: + # confirmed draw if bids are 0 + if bids is not None and not any(bids.values()): + war.events.append(TieResolved(None, context_type, context_id)) + return + # else rank_by_tokens + groups = TieResolver.rank_by_tokens(war, context_type, context_id, participants) + if len(groups[0]) == 1: + war.events.append(TieResolved(groups[0][0], context_type, context_id)) + return + # if tie persists, do nothing, workflow will call again @staticmethod def can_tie_be_resolved(war: War, participants: List[str]) -> bool: @@ -155,3 +165,12 @@ class TieResolver: ): return ev.participant_id is not None return False + + @staticmethod + def is_tie_resolved(war: War, context_type: ContextType, context_id: str) -> bool: + return any( + isinstance(ev, TieResolved) + and ev.context_type == context_type + and ev.context_id == context_id + for ev in war.events + ) diff --git a/src/warchron/model/war_event.py b/src/warchron/model/war_event.py index 72c03f3..62209cd 100644 --- a/src/warchron/model/war_event.py +++ b/src/warchron/model/war_event.py @@ -15,9 +15,11 @@ def register_event(cls: Type[T]) -> Type[T]: class WarEvent: TYPE = "WarEvent" - def __init__(self, participant_id: str | None = None): + def __init__(self, participant_id: str | None, context_type: str, context_id: str): self.id: str = str(uuid4()) self.participant_id: str | None = participant_id + self.context_type = context_type # battle, round, campaign, war + self.context_id = context_id self.timestamp: datetime = datetime.now() def set_id(self, new_id: str) -> None: @@ -34,6 +36,8 @@ class WarEvent: "type": self.TYPE, "id": self.id, "participant_id": self.participant_id, + "context_type": self.context_type, + "context_id": self.context_id, "timestamp": self.timestamp.isoformat(), } @@ -41,6 +45,8 @@ class WarEvent: def _base_fromDict(cls: Type[T], ev: T, data: Dict[str, Any]) -> T: ev.id = data["id"] ev.participant_id = data["participant_id"] + ev.context_type = data["context_type"] + ev.context_id = data["context_id"] ev.timestamp = datetime.fromisoformat(data["timestamp"]) return ev @@ -58,21 +64,10 @@ class TieResolved(WarEvent): TYPE = "TieResolved" def __init__(self, participant_id: str | None, context_type: str, context_id: str): - super().__init__(participant_id) - self.participant_id: str | None = ( - participant_id # winner or None (confirmed tie) - ) - self.context_type = context_type # battle, round, campaign, war - self.context_id = context_id + super().__init__(participant_id, context_type, context_id) def toDict(self) -> Dict[str, Any]: d = super().toDict() - d.update( - { - "context_type": self.context_type, - "context_id": self.context_id, - } - ) return d @classmethod @@ -89,17 +84,17 @@ class TieResolved(WarEvent): class InfluenceGained(WarEvent): TYPE = "InfluenceGained" - def __init__(self, participant_id: str, amount: int, source: str): - super().__init__(participant_id) + def __init__( + self, participant_id: str, amount: int, context_type: str, context_id: str + ): + super().__init__(participant_id, context_type, context_id) self.amount = amount - self.source = source # "battle", "tie_resolution", etc. def toDict(self) -> Dict[str, Any]: d = super().toDict() d.update( { "amount": self.amount, - "source": self.source, } ) return d @@ -108,8 +103,9 @@ class InfluenceGained(WarEvent): def fromDict(cls, data: Dict[str, Any]) -> InfluenceGained: ev = cls( data["participant_id"], - data["amount"], - data["source"], + int(data["amount"]), + data["context_type"], + data["context_id"], ) return cls._base_fromDict(ev, data) @@ -118,17 +114,17 @@ class InfluenceGained(WarEvent): class InfluenceSpent(WarEvent): TYPE = "InfluenceSpent" - def __init__(self, participant_id: str, amount: int, context_type: str): - super().__init__(participant_id) + def __init__( + self, participant_id: str, amount: int, context_type: str, context_id: str + ): + super().__init__(participant_id, context_type, context_id) self.amount = amount - self.context_type = context_type # "battle_tie", "campaign_tie", etc. def toDict(self) -> Dict[str, Any]: d = super().toDict() d.update( { "amount": self.amount, - "context_type": self.context_type, } ) return d @@ -137,7 +133,8 @@ class InfluenceSpent(WarEvent): def fromDict(cls, data: Dict[str, Any]) -> InfluenceSpent: ev = cls( data["participant_id"], - data["amount"], + int(data["amount"]), data["context_type"], + data["context_id"], ) return cls._base_fromDict(ev, data) From c9407f940741b76cc93a7798c73c90160b761a1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20R=C3=A9aux?= Date: Mon, 23 Feb 2026 11:37:50 +0100 Subject: [PATCH 4/5] fix campaign tie loop + dynamic tie dialog --- src/warchron/controller/closure_workflow.py | 19 +--- src/warchron/model/tie_manager.py | 33 +++--- src/warchron/view/tie_dialog.py | 68 +++++++---- src/warchron/view/ui/ui_tie_dialog.py | 102 ++++++----------- src/warchron/view/ui/ui_tie_dialog.ui | 120 ++++---------------- 5 files changed, 120 insertions(+), 222 deletions(-) diff --git a/src/warchron/controller/closure_workflow.py b/src/warchron/controller/closure_workflow.py index 16e0de3..01076d2 100644 --- a/src/warchron/controller/closure_workflow.py +++ b/src/warchron/controller/closure_workflow.py @@ -25,12 +25,7 @@ class RoundClosureWorkflow(ClosureWorkflow): bids_map = self.app.rounds.resolve_ties(war, ties) for tie in ties: bids = bids_map[tie.context_id] - TieResolver.apply_bids( - war, - tie.context_type, - tie.context_id, - bids, - ) + TieResolver.apply_bids(war, tie.context_type, tie.context_id, bids) TieResolver.resolve_tie_state( war, tie.context_type, tie.context_id, tie.participants, bids ) @@ -49,17 +44,9 @@ class CampaignClosureWorkflow(ClosureWorkflow): bids_map = self.app.campaigns.resolve_ties(war, ties) for tie in ties: bids = bids_map[tie.context_id] - TieResolver.apply_bids( - war, - tie.context_type, - tie.context_id, - bids, - ) + TieResolver.apply_bids(war, tie.context_type, tie.context_id, bids) TieResolver.resolve_tie_state( - war, - tie.context_type, - tie.context_id, - tie.participants, + war, tie.context_type, tie.context_id, tie.participants, bids ) ties = TieResolver.find_campaign_ties(war, campaign.id) ClosureService.finalize_campaign(campaign) diff --git a/src/warchron/model/tie_manager.py b/src/warchron/model/tie_manager.py index f9c4054..75cacdb 100644 --- a/src/warchron/model/tie_manager.py +++ b/src/warchron/model/tie_manager.py @@ -16,14 +16,6 @@ class TieContext: participants: List[str] # war_participant_ids -@dataclass -class TieState: - winner: str | None - tied_players: List[str] - eliminated: List[str] - # is_final: bool - - class TieResolver: @staticmethod @@ -65,16 +57,23 @@ class TieResolver: buckets: DefaultDict[int, List[str]] = defaultdict(list) for pid, score in scores.items(): buckets[score.victory_points].append(pid) - ties = [] - for participants in buckets.values(): - if len(participants) > 1: - ties.append( - TieContext( - context_type=ContextType.CAMPAIGN, - context_id=campaign_id, - participants=participants, - ) + ties: List[TieContext] = [] + for score_value, participants in buckets.items(): + if len(participants) <= 1: + continue + tie_id = f"{campaign_id}:score:{score_value}" + if TieResolver.is_tie_resolved(war, ContextType.CAMPAIGN, tie_id): + continue + if not TieResolver.can_tie_be_resolved(war, participants): + war.events.append(TieResolved(None, ContextType.CAMPAIGN, tie_id)) + continue + ties.append( + TieContext( + context_type=ContextType.CAMPAIGN, + context_id=tie_id, + participants=participants, ) + ) return ties @staticmethod diff --git a/src/warchron/view/tie_dialog.py b/src/warchron/view/tie_dialog.py index 201ea32..2e75e48 100644 --- a/src/warchron/view/tie_dialog.py +++ b/src/warchron/view/tie_dialog.py @@ -1,6 +1,15 @@ from typing import List, Dict -from PyQt6.QtWidgets import QWidget, QDialog +from PyQt6.QtWidgets import ( + QWidget, + QDialog, + QCheckBox, + QGroupBox, + QHBoxLayout, + QVBoxLayout, + QLabel, + QLineEdit, +) from PyQt6.QtCore import Qt from warchron.constants import Icons, IconName, ContextType, RESOURCES_DIR @@ -20,32 +29,47 @@ class TieDialog(QDialog): ) -> None: super().__init__(parent) self._context_id = context_id - self._p1_id = players[0].id - self._p2_id = players[1].id + self._checkboxes: Dict[str, QCheckBox] = {} self.ui: Ui_tieDialog = Ui_tieDialog() self.ui.setupUi(self) # type: ignore - self.ui.tieContext.setText(self._get_context_title(context_type)) - icon_path = (RESOURCES_DIR / Icons._paths[IconName.TOKENS]).as_posix() - html = f' Remaining token(s)' - self.ui.label_2.setText(html) - self.ui.label_2.setTextFormat(Qt.TextFormat.RichText) - self.ui.label_3.setText(html) - self.ui.label_3.setTextFormat(Qt.TextFormat.RichText) - self.ui.groupBox_1.setTitle(players[0].name) - self.ui.groupBox_2.setTitle(players[1].name) - self.ui.tokenCount_1.setText(str(counters[0])) - self.ui.tokenCount_2.setText(str(counters[1])) - if counters[0] < 1: - self.ui.tokenSpend_1.setDisabled(True) - if counters[1] < 1: - self.ui.tokenSpend_2.setDisabled(True) self.setWindowIcon(Icons.get(IconName.WARCHRON)) + self.ui.tieContext.setText(self._get_context_title(context_type)) + grid = self.ui.playersGridLayout + icon_path = (RESOURCES_DIR / Icons._paths[IconName.TOKENS]).as_posix() + token_html = ( + f' Remaining token(s)' + ) + for i, (player, tokens) in enumerate(zip(players, counters)): + group = QGroupBox(player.name) + row_layout = QHBoxLayout() + left = QVBoxLayout() + spend_label = QLabel("Spend token") + token_label = QLabel(token_html) + token_label.setTextFormat(Qt.TextFormat.RichText) + left.addWidget(spend_label) + left.addWidget(token_label) + right = QVBoxLayout() + checkbox = QCheckBox() + count = QLineEdit(str(tokens)) + count.setEnabled(False) + if tokens < 1: + checkbox.setDisabled(True) + right.addWidget(checkbox) + right.addWidget(count) + row_layout.addLayout(left) + row_layout.addLayout(right) + group.setLayout(row_layout) + row = i // 2 + col = i % 2 + grid.addWidget(group, row, col) + self._checkboxes[player.id] = checkbox + grid.setColumnStretch(0, 1) + grid.setColumnStretch(1, 1) + self.ui.playersScrollArea.setMinimumHeight(110) + self.adjustSize() def get_bids(self) -> Dict[str, bool]: - return { - self._p1_id: self.ui.tokenSpend_1.isChecked(), - self._p2_id: self.ui.tokenSpend_2.isChecked(), - } + return {pid: checkbox.isChecked() for pid, checkbox in self._checkboxes.items()} @staticmethod def _get_context_title(context_type: ContextType) -> str: diff --git a/src/warchron/view/ui/ui_tie_dialog.py b/src/warchron/view/ui/ui_tie_dialog.py index 1bfc512..0361373 100644 --- a/src/warchron/view/ui/ui_tie_dialog.py +++ b/src/warchron/view/ui/ui_tie_dialog.py @@ -13,12 +13,16 @@ class Ui_tieDialog(object): def setupUi(self, tieDialog): tieDialog.setObjectName("tieDialog") tieDialog.setWindowModality(QtCore.Qt.WindowModality.ApplicationModal) - tieDialog.resize(477, 174) + tieDialog.resize(481, 155) icon = QtGui.QIcon() - icon.addPixmap(QtGui.QPixmap(".\\src\\warchron\\view\\ui\\../resources/warchron_logo.png"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off) + icon.addPixmap( + QtGui.QPixmap(".\\src\\warchron\\view\\ui\\../resources/warchron_logo.png"), + QtGui.QIcon.Mode.Normal, + QtGui.QIcon.State.Off, + ) tieDialog.setWindowIcon(icon) - self.verticalLayout_5 = QtWidgets.QVBoxLayout(tieDialog) - self.verticalLayout_5.setObjectName("verticalLayout_5") + self.gridLayout = QtWidgets.QGridLayout(tieDialog) + self.gridLayout.setObjectName("gridLayout") self.horizontalLayout_3 = QtWidgets.QHBoxLayout() self.horizontalLayout_3.setObjectName("horizontalLayout_3") self.tieContext = QtWidgets.QLabel(parent=tieDialog) @@ -29,87 +33,47 @@ class Ui_tieDialog(object): self.tieContext.setFont(font) self.tieContext.setObjectName("tieContext") self.horizontalLayout_3.addWidget(self.tieContext) - spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) + spacerItem = QtWidgets.QSpacerItem( + 40, + 20, + QtWidgets.QSizePolicy.Policy.Expanding, + QtWidgets.QSizePolicy.Policy.Minimum, + ) self.horizontalLayout_3.addItem(spacerItem) - self.verticalLayout_5.addLayout(self.horizontalLayout_3) - self.horizontalLayout_4 = QtWidgets.QHBoxLayout() - self.horizontalLayout_4.setObjectName("horizontalLayout_4") - self.groupBox_1 = QtWidgets.QGroupBox(parent=tieDialog) - self.groupBox_1.setObjectName("groupBox_1") - self.horizontalLayout = QtWidgets.QHBoxLayout(self.groupBox_1) - self.horizontalLayout.setObjectName("horizontalLayout") - self.verticalLayout = QtWidgets.QVBoxLayout() - self.verticalLayout.setObjectName("verticalLayout") - self.label_5 = QtWidgets.QLabel(parent=self.groupBox_1) - self.label_5.setObjectName("label_5") - self.verticalLayout.addWidget(self.label_5) - self.label_2 = QtWidgets.QLabel(parent=self.groupBox_1) - self.label_2.setObjectName("label_2") - self.verticalLayout.addWidget(self.label_2) - self.horizontalLayout.addLayout(self.verticalLayout) - self.verticalLayout_2 = QtWidgets.QVBoxLayout() - self.verticalLayout_2.setObjectName("verticalLayout_2") - self.tokenSpend_1 = QtWidgets.QCheckBox(parent=self.groupBox_1) - self.tokenSpend_1.setText("") - self.tokenSpend_1.setObjectName("tokenSpend_1") - self.verticalLayout_2.addWidget(self.tokenSpend_1) - self.tokenCount_1 = QtWidgets.QLineEdit(parent=self.groupBox_1) - self.tokenCount_1.setEnabled(False) - self.tokenCount_1.setObjectName("tokenCount_1") - self.verticalLayout_2.addWidget(self.tokenCount_1) - self.horizontalLayout.addLayout(self.verticalLayout_2) - self.horizontalLayout_4.addWidget(self.groupBox_1) - self.groupBox_2 = QtWidgets.QGroupBox(parent=tieDialog) - self.groupBox_2.setObjectName("groupBox_2") - self.horizontalLayout_2 = QtWidgets.QHBoxLayout(self.groupBox_2) - self.horizontalLayout_2.setObjectName("horizontalLayout_2") - self.verticalLayout_3 = QtWidgets.QVBoxLayout() - self.verticalLayout_3.setObjectName("verticalLayout_3") - self.label_6 = QtWidgets.QLabel(parent=self.groupBox_2) - self.label_6.setObjectName("label_6") - self.verticalLayout_3.addWidget(self.label_6) - self.label_3 = QtWidgets.QLabel(parent=self.groupBox_2) - self.label_3.setObjectName("label_3") - self.verticalLayout_3.addWidget(self.label_3) - self.horizontalLayout_2.addLayout(self.verticalLayout_3) - self.verticalLayout_4 = QtWidgets.QVBoxLayout() - self.verticalLayout_4.setObjectName("verticalLayout_4") - self.tokenSpend_2 = QtWidgets.QCheckBox(parent=self.groupBox_2) - self.tokenSpend_2.setText("") - self.tokenSpend_2.setObjectName("tokenSpend_2") - self.verticalLayout_4.addWidget(self.tokenSpend_2) - self.tokenCount_2 = QtWidgets.QLineEdit(parent=self.groupBox_2) - self.tokenCount_2.setEnabled(False) - self.tokenCount_2.setObjectName("tokenCount_2") - self.verticalLayout_4.addWidget(self.tokenCount_2) - self.horizontalLayout_2.addLayout(self.verticalLayout_4) - self.horizontalLayout_4.addWidget(self.groupBox_2) - self.verticalLayout_5.addLayout(self.horizontalLayout_4) + self.gridLayout.addLayout(self.horizontalLayout_3, 0, 0, 1, 1) + self.playersScrollArea = QtWidgets.QScrollArea(parent=tieDialog) + self.playersScrollArea.setWidgetResizable(True) + self.playersScrollArea.setObjectName("playersScrollArea") + self.scrollAreaWidgetContents = QtWidgets.QWidget() + self.scrollAreaWidgetContents.setGeometry(QtCore.QRect(0, 0, 461, 78)) + self.scrollAreaWidgetContents.setObjectName("scrollAreaWidgetContents") + self.playersGridLayout = QtWidgets.QGridLayout(self.scrollAreaWidgetContents) + self.playersGridLayout.setObjectName("playersGridLayout") + self.playersScrollArea.setWidget(self.scrollAreaWidgetContents) + self.gridLayout.addWidget(self.playersScrollArea, 1, 0, 1, 1) self.buttonBox = QtWidgets.QDialogButtonBox(parent=tieDialog) self.buttonBox.setOrientation(QtCore.Qt.Orientation.Horizontal) - self.buttonBox.setStandardButtons(QtWidgets.QDialogButtonBox.StandardButton.Cancel|QtWidgets.QDialogButtonBox.StandardButton.Ok) + self.buttonBox.setStandardButtons( + QtWidgets.QDialogButtonBox.StandardButton.Cancel + | QtWidgets.QDialogButtonBox.StandardButton.Ok + ) self.buttonBox.setObjectName("buttonBox") - self.verticalLayout_5.addWidget(self.buttonBox) + self.gridLayout.addWidget(self.buttonBox, 2, 0, 1, 1) self.retranslateUi(tieDialog) - self.buttonBox.accepted.connect(tieDialog.accept) # type: ignore - self.buttonBox.rejected.connect(tieDialog.reject) # type: ignore + self.buttonBox.accepted.connect(tieDialog.accept) # type: ignore + self.buttonBox.rejected.connect(tieDialog.reject) # type: ignore QtCore.QMetaObject.connectSlotsByName(tieDialog) def retranslateUi(self, tieDialog): _translate = QtCore.QCoreApplication.translate tieDialog.setWindowTitle(_translate("tieDialog", "Tie")) self.tieContext.setText(_translate("tieDialog", "Battle tie")) - self.groupBox_1.setTitle(_translate("tieDialog", "Player 1")) - self.label_5.setText(_translate("tieDialog", "Spend token")) - self.label_2.setText(_translate("tieDialog", "Remaining token(s)")) - self.groupBox_2.setTitle(_translate("tieDialog", "Player 2")) - self.label_6.setText(_translate("tieDialog", "Spend token")) - self.label_3.setText(_translate("tieDialog", "Remaining token(s)")) if __name__ == "__main__": import sys + app = QtWidgets.QApplication(sys.argv) tieDialog = QtWidgets.QDialog() ui = Ui_tieDialog() diff --git a/src/warchron/view/ui/ui_tie_dialog.ui b/src/warchron/view/ui/ui_tie_dialog.ui index ccceb54..38e6744 100644 --- a/src/warchron/view/ui/ui_tie_dialog.ui +++ b/src/warchron/view/ui/ui_tie_dialog.ui @@ -9,8 +9,8 @@ 0 0 - 477 - 174 + 481 + 155 @@ -20,8 +20,8 @@ ../resources/warchron_logo.png../resources/warchron_logo.png - - + + @@ -52,101 +52,25 @@ - - - - - - Player 1 - - - - - - - - Spend token - - - - - - - Remaining token(s) - - - - - - - - - - - - - - - - - - false - - - - - - - - - - - - Player 2 - - - - - - - - Spend token - - - - - - - Remaining token(s) - - - - - - - - - - - - - - - - - - false - - - - - - - - - + + + + true + + + + + 0 + 0 + 461 + 78 + + + + + - + Qt::Horizontal From 60992c22df206ea2d4c5bff5c6787fca7b69125a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20R=C3=A9aux?= Date: Mon, 23 Feb 2026 13:16:45 +0100 Subject: [PATCH 5/5] fix tie-break loser re-bid --- .../controller/campaign_controller.py | 14 ++++---- src/warchron/model/tie_manager.py | 36 ++++++++++++++++--- 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/src/warchron/controller/campaign_controller.py b/src/warchron/controller/campaign_controller.py index 4c72427..9447058 100644 --- a/src/warchron/controller/campaign_controller.py +++ b/src/warchron/controller/campaign_controller.py @@ -18,7 +18,7 @@ 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 +from warchron.model.tie_manager import TieContext, TieResolver from warchron.model.score_service import ScoreService from warchron.view.campaign_dialog import CampaignDialog from warchron.view.campaign_participant_dialog import CampaignParticipantDialog @@ -144,14 +144,14 @@ class CampaignController: ) -> Dict[str, Dict[str, bool]]: bids_map = {} for ctx in contexts: + active = TieResolver.get_active_participants( + war, ctx.context_type, ctx.context_id, ctx.participants + ) players = [ - ParticipantOption( - id=pid, - name=self.app.model.get_participant_name(pid), - ) - for pid in ctx.participants + ParticipantOption(id=pid, name=self.app.model.get_participant_name(pid)) + for pid in active ] - counters = [war.get_influence_tokens(pid) for pid in ctx.participants] + counters = [war.get_influence_tokens(pid) for pid in active] dialog = TieDialog( parent=self.app.view, players=players, diff --git a/src/warchron/model/tie_manager.py b/src/warchron/model/tie_manager.py index 75cacdb..fd678d7 100644 --- a/src/warchron/model/tie_manager.py +++ b/src/warchron/model/tie_manager.py @@ -35,7 +35,9 @@ class TieResolver: raise RuntimeError("Missing player(s) in this battle context.") p1 = campaign.participants[battle.player_1_id].war_participant_id p2 = campaign.participants[battle.player_2_id].war_participant_id - if not TieResolver.can_tie_be_resolved(war, [p1, p2]): + if not TieResolver.can_tie_be_resolved( + war, ContextType.BATTLE, battle.sector_id, [p1, p2] + ): war.events.append( TieResolved(None, ContextType.BATTLE, battle.sector_id) ) @@ -64,7 +66,9 @@ class TieResolver: tie_id = f"{campaign_id}:score:{score_value}" if TieResolver.is_tie_resolved(war, ContextType.CAMPAIGN, tie_id): continue - if not TieResolver.can_tie_be_resolved(war, participants): + if not TieResolver.can_tie_be_resolved( + war, ContextType.CAMPAIGN, tie_id, participants + ): war.events.append(TieResolved(None, ContextType.CAMPAIGN, tie_id)) continue ties.append( @@ -127,6 +131,16 @@ class TieResolver: groups[-1].append(pid) return groups + @staticmethod + def get_active_participants( + war: War, + context_type: ContextType, + context_id: str, + participants: List[str], + ) -> List[str]: + groups = TieResolver.rank_by_tokens(war, context_type, context_id, participants) + return groups[0] + @staticmethod def resolve_tie_state( war: War, @@ -135,7 +149,14 @@ class TieResolver: participants: List[str], bids: dict[str, bool] | None = None, ) -> None: - # confirmed draw if bids are 0 + active = TieResolver.get_active_participants( + war, context_type, context_id, participants + ) + # confirmed draw if non had bid + if not active: + war.events.append(TieResolved(None, context_type, context_id)) + return + # confirmed draw if current bids are 0 if bids is not None and not any(bids.values()): war.events.append(TieResolved(None, context_type, context_id)) return @@ -147,8 +168,13 @@ class TieResolver: # if tie persists, do nothing, workflow will call again @staticmethod - def can_tie_be_resolved(war: War, participants: List[str]) -> bool: - return any(war.get_influence_tokens(pid) > 0 for pid in participants) + def can_tie_be_resolved( + war: War, context_type: ContextType, context_id: str, participants: List[str] + ) -> bool: + active = TieResolver.get_active_participants( + war, context_type, context_id, participants + ) + return any(war.get_influence_tokens(pid) > 0 for pid in active) @staticmethod def was_tie_broken_by_tokens(