detect campaign tie

This commit is contained in:
Maxime Réaux 2026-02-20 11:01:25 +01:00
parent 7c9c941864
commit 60d8e6ca15
9 changed files with 203 additions and 116 deletions

View file

@ -1,8 +1,8 @@
from typing import List, TYPE_CHECKING from typing import List, Dict, TYPE_CHECKING
from PyQt6.QtWidgets import QMessageBox, QDialog from PyQt6.QtWidgets import QMessageBox, QDialog
from warchron.constants import RefreshScope, ContextType from warchron.constants import RefreshScope, ContextType, ItemType
if TYPE_CHECKING: if TYPE_CHECKING:
from warchron.controller.app_controller import AppController from warchron.controller.app_controller import AppController
@ -13,14 +13,18 @@ from warchron.controller.dtos import (
RoundDTO, RoundDTO,
CampaignParticipantScoreDTO, CampaignParticipantScoreDTO,
) )
from warchron.model.exception import ForbiddenOperation, DomainError
from warchron.model.war import War
from warchron.model.campaign import Campaign from warchron.model.campaign import Campaign
from warchron.model.campaign_participant import CampaignParticipant from warchron.model.campaign_participant import CampaignParticipant
from warchron.model.sector import Sector 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.model.score_service import ScoreService
from warchron.view.campaign_dialog import CampaignDialog from warchron.view.campaign_dialog import CampaignDialog
from warchron.view.campaign_participant_dialog import CampaignParticipantDialog from warchron.view.campaign_participant_dialog import CampaignParticipantDialog
from warchron.view.sector_dialog import SectorDialog from warchron.view.sector_dialog import SectorDialog
from warchron.controller.closure_workflow import CampaignClosureWorkflow
from warchron.view.tie_dialog import TieDialog
class CampaignController: class CampaignController:
@ -101,10 +105,6 @@ class CampaignController:
return self.app.model.add_campaign( return self.app.model.add_campaign(
self.app.navigation.selected_war_id, name, month 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: def edit_campaign(self, campaign_id: str) -> None:
camp = self.app.model.get_campaign(campaign_id) camp = self.app.model.get_campaign(campaign_id)
@ -123,25 +123,46 @@ class CampaignController:
if not campaign_id: if not campaign_id:
return return
camp = self.app.model.get_campaign(campaign_id) camp = self.app.model.get_campaign(campaign_id)
if camp.is_over: war = self.app.model.get_war_by_campaign(campaign_id)
return workflow = CampaignClosureWorkflow(self.app)
try: try:
ties = ClosureService.close_campaign(camp) workflow.start(war, camp)
except RuntimeError as e: except DomainError as e:
QMessageBox.warning(self.app.view, "Cannot close campaign", str(e)) QMessageBox.warning(
return
if ties:
QMessageBox.information(
self.app.view, self.app.view,
"Tie detected", "Deletion forbidden",
"Campaign has unresolved ties.", str(e),
) )
return
self.app.is_dirty = True self.app.is_dirty = True
self.app.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) 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: def create_campaign_participant(self) -> CampaignParticipant | None:
if not self.app.navigation.selected_campaign_id: if not self.app.navigation.selected_campaign_id:

View file

@ -3,16 +3,12 @@ from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
from warchron.controller.app_controller import AppController 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_event import TieResolved
from warchron.model.war import War from warchron.model.war import War
from warchron.model.campaign import Campaign from warchron.model.campaign import Campaign
from warchron.model.battle import Battle
from warchron.model.round import Round from warchron.model.round import Round
from warchron.model.closure_service import ClosureService from warchron.model.closure_service import ClosureService
from warchron.model.tie_manager import TieResolver from warchron.model.tie_manager import TieResolver
from warchron.controller.dtos import TieContext
class ClosureWorkflow: class ClosureWorkflow:
@ -25,53 +21,77 @@ class RoundClosureWorkflow(ClosureWorkflow):
def start(self, war: War, campaign: Campaign, round: Round) -> None: def start(self, war: War, campaign: Campaign, round: Round) -> None:
ClosureService.check_round_closable(round) ClosureService.check_round_closable(round)
ties = TieResolver.find_round_ties(round, war) ties = TieResolver.find_battle_ties(war, round.id)
while ties: while ties:
contexts = [
RoundClosureWorkflow.build_battle_context(campaign, b) for b in ties
]
resolvable = [] resolvable = []
for ctx in contexts: for tie in ties:
if TieResolver.can_tie_be_resolved(war, ctx.participants): if TieResolver.can_tie_be_resolved(war, tie.participants):
resolvable.append(ctx) resolvable.append(tie)
else: else:
war.events.append( war.events.append(
TieResolved( TieResolved(
participant_id=None, participant_id=None, # draw confirmed
context_type=ctx.context_type, context_type=tie.context_type,
context_id=ctx.context_id, context_id=tie.context_id,
) )
) )
if not resolvable: if not resolvable:
break break
bids_map = self.app.rounds.resolve_ties(war, contexts) bids_map = self.app.rounds.resolve_ties(war, resolvable)
for ctx in contexts: for tie in resolvable:
bids = bids_map[ctx.context_id] bids = bids_map[tie.context_id]
TieResolver.apply_bids( TieResolver.apply_bids(
war, war,
ctx.context_type, tie.context_type,
ctx.context_id, tie.context_id,
bids, bids,
) )
TieResolver.try_tie_break( TieResolver.try_tie_break(
war, war,
ctx.context_type, tie.context_type,
ctx.context_id, tie.context_id,
ctx.participants, tie.participants,
) )
ties = TieResolver.find_round_ties(round, war) ties = TieResolver.find_battle_ties(war, round.id)
for battle in round.battles.values(): for battle in round.battles.values():
ClosureService.apply_battle_outcomes(war, campaign, battle) ClosureService.apply_battle_outcomes(war, campaign, battle)
ClosureService.finalize_round(round) ClosureService.finalize_round(round)
@staticmethod
def build_battle_context(campaign: Campaign, battle: Battle) -> TieContext: class CampaignClosureWorkflow(ClosureWorkflow):
if battle.player_1_id is None or battle.player_2_id is None:
raise ForbiddenOperation("Missing player(s) in this battle context.") def start(self, war: War, campaign: Campaign) -> None:
p1 = campaign.participants[battle.player_1_id].war_participant_id ClosureService.check_campaign_closable(campaign)
p2 = campaign.participants[battle.player_2_id].war_participant_id ties = TieResolver.find_campaign_ties(war, campaign.id)
return TieContext( while ties:
context_type=ContextType.BATTLE, resolvable = []
context_id=battle.sector_id, for tie in ties:
participants=[p1, p2], 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)

View file

@ -3,8 +3,6 @@ from dataclasses import dataclass
from PyQt6.QtGui import QIcon from PyQt6.QtGui import QIcon
from warchron.constants import ContextType
@dataclass(frozen=True) @dataclass(frozen=True)
class ParticipantOption: class ParticipantOption:
@ -109,13 +107,6 @@ class BattleDTO:
player2_tooltip: str | None = None 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) @dataclass(frozen=True, slots=True)
class ParticipantScoreDTO: class ParticipantScoreDTO:
participant_id: str participant_id: str

View file

@ -6,7 +6,8 @@ from PyQt6.QtGui import QIcon
from warchron.constants import ItemType, RefreshScope, Icons, IconName, ContextType from warchron.constants import ItemType, RefreshScope, Icons, IconName, ContextType
from warchron.model.exception import ForbiddenOperation, DomainError 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.round import Round
from warchron.model.war import War from warchron.model.war import War
@ -18,7 +19,6 @@ from warchron.controller.dtos import (
SectorDTO, SectorDTO,
ChoiceDTO, ChoiceDTO,
BattleDTO, BattleDTO,
TieContext,
) )
from warchron.controller.closure_workflow import RoundClosureWorkflow from warchron.controller.closure_workflow import RoundClosureWorkflow
from warchron.view.choice_dialog import ChoiceDialog from warchron.view.choice_dialog import ChoiceDialog
@ -108,7 +108,7 @@ class RoundController:
if TieResolver.was_tie_broken_by_tokens( if TieResolver.was_tie_broken_by_tokens(
war, ContextType.BATTLE, battle.sector_id 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 war, ContextType.BATTLE, battle.sector_id, None
) )
p1_war = None p1_war = None

View file

@ -3,7 +3,7 @@ from typing import List
from warchron.constants import ContextType from warchron.constants import ContextType
from warchron.model.exception import ForbiddenOperation 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_event import InfluenceGained
from warchron.model.war import War from warchron.model.war import War
from warchron.model.campaign import Campaign from warchron.model.campaign import Campaign
@ -35,7 +35,7 @@ class ClosureService:
base_winner = None base_winner = None
if battle.winner_id is not None: if battle.winner_id is not None:
base_winner = campaign.participants[battle.winner_id].war_participant_id 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, war,
ContextType.BATTLE, ContextType.BATTLE,
battle.sector_id, battle.sector_id,
@ -60,28 +60,17 @@ class ClosureService:
# Campaign methods # Campaign methods
@staticmethod @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(): if not campaign.all_rounds_finished():
raise RuntimeError("All rounds must be finished to close their campaign") raise ForbiddenOperation(
ties: List[str] = [] "All rounds must be closed to close their campaign"
# for round in campaign.rounds: )
# # compute score
# # if participants have same score @staticmethod
# ties.append( def finalize_campaign(campaign: Campaign) -> None:
# 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
campaign.is_over = True campaign.is_over = True
return []
# War methods # War methods

View file

@ -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

View file

@ -1,8 +1,8 @@
from typing import Dict, Iterator from typing import Dict, Iterator
from dataclasses import dataclass, field from dataclasses import dataclass, field
from warchron.model.result_checker import ResultChecker
from warchron.constants import ContextType from warchron.constants import ContextType
from warchron.model.tie_manager import TieResolver
from warchron.model.war import War from warchron.model.war import War
from warchron.model.battle import Battle from warchron.model.battle import Battle
@ -59,7 +59,7 @@ class ScoreService:
base_winner = campaign.participants[ base_winner = campaign.participants[
battle.winner_id battle.winner_id
].war_participant_id ].war_participant_id
winner = TieResolver.get_effective_winner_id( winner = ResultChecker.get_effective_winner_id(
war, ContextType.BATTLE, battle.sector_id, base_winner war, ContextType.BATTLE, battle.sector_id, base_winner
) )
if winner is None: if winner is None:

View file

@ -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.constants import ContextType
from warchron.model.exception import ForbiddenOperation from warchron.model.exception import ForbiddenOperation
from warchron.model.war import War 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.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: class TieResolver:
@staticmethod @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 = [] ties = []
for battle in round.battles.values(): for battle in round.battles.values():
if not battle.is_draw(): if not battle.is_draw():
@ -22,10 +32,53 @@ class TieResolver:
and e.context_id == battle.sector_id and e.context_id == battle.sector_id
for e in war.events for e in war.events
) )
if not resolved: if resolved:
ties.append(battle) 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 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 @staticmethod
def apply_bids( def apply_bids(
war: War, war: War,
@ -88,25 +141,6 @@ class TieResolver:
def can_tie_be_resolved(war: War, participants: List[str]) -> bool: def can_tie_be_resolved(war: War, participants: List[str]) -> bool:
return any(war.get_influence_tokens(pid) > 0 for pid in participants) 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 @staticmethod
def was_tie_broken_by_tokens( def was_tie_broken_by_tokens(
war: War, war: War,

View file

@ -371,6 +371,14 @@ class War:
# Round methods # 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: def add_round(self, campaign_id: str) -> Round:
camp = self.get_campaign(campaign_id) camp = self.get_campaign(campaign_id)
return camp.add_round() return camp.add_round()
@ -420,7 +428,7 @@ class War:
for bat in rnd.battles.values(): for bat in rnd.battles.values():
if bat.sector_id == battle_id: if bat.sector_id == battle_id:
return bat return bat
raise KeyError("Round not found") raise KeyError("Battle not found")
def create_battle(self, round_id: str, sector_id: str) -> Battle: def create_battle(self, round_id: str, sector_id: str) -> Battle:
camp = self.get_campaign_by_round(round_id) camp = self.get_campaign_by_round(round_id)