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

View file

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

View file

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

View file

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

View file

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

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

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.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,

View file

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