unifomise tiebreak icons + refacto presenter

This commit is contained in:
Maxime Réaux 2026-03-19 15:10:48 +01:00
parent 0081e52e9a
commit 3d0d7874e3
10 changed files with 185 additions and 165 deletions

View file

@ -49,7 +49,8 @@ class IconName(StrEnum):
NP1ST = auto() NP1ST = auto()
NP2ND = auto() NP2ND = auto()
NP3RD = auto() NP3RD = auto()
TIEBREAK_TOKEN = auto() WINTOKEN = auto()
TIEBREAKTOKEN = auto()
VP1STDRAW = auto() VP1STDRAW = auto()
VP1STBREAK = auto() VP1STBREAK = auto()
VP1STTIEDRAW = auto() VP1STTIEDRAW = auto()
@ -143,11 +144,16 @@ class Icons:
def get_pixmap(cls, name: IconName) -> QPixmap: def get_pixmap(cls, name: IconName) -> QPixmap:
if name in cls._pixmap_cache: if name in cls._pixmap_cache:
return cls._pixmap_cache[name] return cls._pixmap_cache[name]
if name == IconName.TIEBREAK_TOKEN: if name == IconName.TIEBREAKTOKEN:
pix = cls._compose( pix = cls._compose(
cls.get_pixmap(IconName.TIEBREAK), cls.get_pixmap(IconName.TIEBREAK),
cls.get_pixmap(IconName.TOKEN), cls.get_pixmap(IconName.TOKEN),
) )
elif name == IconName.WINTOKEN:
pix = cls._compose(
cls.get_pixmap(IconName.WIN),
cls.get_pixmap(IconName.TOKEN),
)
elif name == IconName.VP1STDRAW: elif name == IconName.VP1STDRAW:
pix = cls._compose( pix = cls._compose(
cls.get_pixmap(IconName.VP1ST), cls.get_pixmap(IconName.VP1ST),

View file

@ -22,8 +22,8 @@ from warchron.model.sector import Sector
from warchron.model.tie_manager import TieContext, TieResolver from warchron.model.tie_manager import TieContext, TieResolver
from warchron.model.score_service import ScoreService from warchron.model.score_service import ScoreService
from warchron.controller.closure_workflow import CampaignClosureWorkflow from warchron.controller.closure_workflow import CampaignClosureWorkflow
from warchron.controller.ranking_icon import RankingIcon
from warchron.controller.presenter import TiePresenter from warchron.controller.presenter import Presenter
from warchron.view.campaign_dialog import CampaignDialog from warchron.view.campaign_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
@ -58,11 +58,11 @@ class CampaignController:
vp_icon_map: Dict[str, QIcon] = {} vp_icon_map: Dict[str, QIcon] = {}
objective_icon_maps: Dict[str, Dict[str, QIcon]] = {} objective_icon_maps: Dict[str, Dict[str, QIcon]] = {}
if camp.is_over: if camp.is_over:
vp_icon_map = RankingIcon.compute_icons( vp_icon_map = Presenter.compute_ranking_icons(
war, ContextType.CAMPAIGN, campaign_id, scores war, ContextType.CAMPAIGN, campaign_id, scores
) )
for obj in war.get_objectives_used_as_maj_or_min(): for obj in war.get_objectives_used_as_maj_or_min():
objective_icon_maps[obj.id] = RankingIcon.compute_icons( objective_icon_maps[obj.id] = Presenter.compute_ranking_icons(
war, war,
ContextType.CAMPAIGN, ContextType.CAMPAIGN,
camp.id, camp.id,
@ -177,7 +177,7 @@ class CampaignController:
for pid in active for pid in active
] ]
counters = [war.get_influence_tokens(pid) for pid in active] counters = [war.get_influence_tokens(pid) for pid in active]
data = TiePresenter.build_dialog_data( data = Presenter.build_dialog_data(
war, ctx, campaign=war.get_campaign(ctx.context_id) war, ctx, campaign=war.get_campaign(ctx.context_id)
) )
dialog = TieDialog( dialog = TieDialog(

View file

@ -1,12 +1,27 @@
from warchron.constants import ContextType from typing import Dict
from PyQt6.QtGui import QIcon
from warchron.constants import (
ContextType,
Icons,
IconName,
VP_RANK_TO_ICON,
NP_RANK_TO_ICON,
ScoreKind,
)
from warchron.controller.dtos import TieDialogData from warchron.controller.dtos import TieDialogData
from warchron.model.tie_manager import TieContext from warchron.model.tie_manager import TieContext, TieResolver
from warchron.model.war import War from warchron.model.war import War
from warchron.model.campaign import Campaign from warchron.model.campaign import Campaign
from warchron.model.round import Round from warchron.model.round import Round
from warchron.model.score_service import ParticipantScore
from warchron.model.result_checker import ResultChecker
from warchron.model.exception import DomainError
class TiePresenter: class Presenter:
@staticmethod @staticmethod
def build_dialog_data( def build_dialog_data(
@ -33,7 +48,7 @@ class TiePresenter:
if ctx.context_type == ContextType.CHOICE: if ctx.context_type == ContextType.CHOICE:
if ctx.sector_id and campaign and round: if ctx.sector_id and campaign and round:
sector = campaign.sectors[ctx.sector_id] sector = campaign.sectors[ctx.sector_id]
kind = TiePresenter._choice_kind(round, ctx) kind = Presenter._choice_kind(round, ctx)
return TieDialogData(f"Choice tie — {sector.name} ({kind})") return TieDialogData(f"Choice tie — {sector.name} ({kind})")
return TieDialogData("Choice tie") return TieDialogData("Choice tie")
return TieDialogData("Tie") return TieDialogData("Tie")
@ -50,3 +65,114 @@ class TiePresenter:
if choice.secondary_sector_id == ctx.sector_id: if choice.secondary_sector_id == ctx.sector_id:
return "secondary" return "secondary"
return "choice" return "choice"
@staticmethod
def compute_ranking_icons(
war: War,
context_type: ContextType,
context_id: str,
scores: Dict[str, ParticipantScore],
*,
objective_id: str | None = None,
) -> Dict[str, QIcon]:
if objective_id is None:
def value_getter(score: ParticipantScore) -> int:
return score.victory_points
icon_ranking = VP_RANK_TO_ICON
score_kind = ScoreKind.VP
else:
def value_getter(score: ParticipantScore) -> int:
return score.narrative_points.get(objective_id, 0)
icon_ranking = NP_RANK_TO_ICON
score_kind = ScoreKind.NP
ranking = ResultChecker.get_effective_ranking(
war,
context_type,
context_id,
score_kind,
scores,
value_getter,
objective_id,
)
icon_map: Dict[str, QIcon] = {}
for rank, group, token_map in ranking:
if objective_id and rank not in icon_ranking:
continue
base_icon = icon_ranking.get(
rank, IconName.VPNTH if objective_id is None else None
)
if base_icon is None:
continue
value = value_getter(scores[group[0]])
original_group_size = sum(
1 for s in scores.values() if value_getter(s) == value
)
for pid in group:
spent = token_map.get(pid, 0)
if original_group_size == 1:
icon_name = base_icon
elif len(group) == 1:
if spent > 0:
icon_name = getattr(IconName, f"{base_icon.name}BREAK")
else:
icon_name = base_icon
else:
if spent > 0:
icon_name = getattr(IconName, f"{base_icon.name}TIEDRAW")
else:
icon_name = getattr(IconName, f"{base_icon.name}DRAW")
icon_map[pid] = QIcon(Icons.get_pixmap(icon_name))
return icon_map
@staticmethod
def compute_battle_icons(
war: War,
round_id: str,
battle_id: str,
) -> tuple[QIcon | None, QIcon | None]:
battle = war.get_battle(battle_id)
if not (battle.player_1_id and battle.player_2_id and battle.is_finished()):
return None, None
campaign = war.get_campaign_by_sector(battle.sector_id)
if not campaign:
raise DomainError("No campaign found for this battle")
base_winner = None
if battle.winner_id is not None:
base_winner = campaign.campaign_to_war_part_id(battle.winner_id)
winner_id = ResultChecker.get_effective_winner_id(
war,
ContextType.BATTLE,
battle.sector_id,
base_winner,
)
def compute_icon(player: str) -> QIcon | None:
base_icon: IconName | None = None
if winner_id is None:
base_icon = IconName.DRAW
elif campaign.war_to_campaign_part_id(winner_id) == player:
base_icon = IconName.WIN
elif battle.is_draw():
base_icon = IconName.TIEBREAK
if base_icon is None:
return None
spent = TieResolver.participant_spent_token(
war,
ContextType.BATTLE,
battle.sector_id,
None,
campaign.campaign_to_war_part_id(player),
)
icon_name = (
getattr(IconName, f"{base_icon.name}TOKEN") if spent else base_icon
)
return QIcon(Icons.get_pixmap(icon_name))
return (
compute_icon(battle.player_1_id),
compute_icon(battle.player_2_id),
)

View file

@ -1,80 +0,0 @@
from typing import Dict
from PyQt6.QtGui import QIcon
from warchron.constants import (
ContextType,
Icons,
IconName,
VP_RANK_TO_ICON,
NP_RANK_TO_ICON,
ScoreKind,
)
from warchron.model.war import War
from warchron.model.score_service import ParticipantScore
from warchron.model.result_checker import ResultChecker
class RankingIcon:
@staticmethod
def compute_icons(
war: War,
context_type: ContextType,
context_id: str,
scores: Dict[str, ParticipantScore],
*,
objective_id: str | None = None,
) -> Dict[str, QIcon]:
if objective_id is None:
def value_getter(score: ParticipantScore) -> int:
return score.victory_points
icon_ranking = VP_RANK_TO_ICON
score_kind = ScoreKind.VP
else:
def value_getter(score: ParticipantScore) -> int:
return score.narrative_points.get(objective_id, 0)
icon_ranking = NP_RANK_TO_ICON
score_kind = ScoreKind.NP
ranking = ResultChecker.get_effective_ranking(
war,
context_type,
context_id,
score_kind,
scores,
value_getter,
objective_id,
)
icon_map: Dict[str, QIcon] = {}
for rank, group, token_map in ranking:
if objective_id and rank not in icon_ranking:
continue
base_icon = icon_ranking.get(
rank, IconName.VPNTH if objective_id is None else None
)
if base_icon is None:
continue
value = value_getter(scores[group[0]])
original_group_size = sum(
1 for s in scores.values() if value_getter(s) == value
)
for pid in group:
spent = token_map.get(pid, 0)
if original_group_size == 1:
icon_name = base_icon
elif len(group) == 1:
if spent > 0:
icon_name = getattr(IconName, f"{base_icon.name}BREAK")
else:
icon_name = base_icon
else:
if spent > 0:
icon_name = getattr(IconName, f"{base_icon.name}TIEDRAW")
else:
icon_name = getattr(IconName, f"{base_icon.name}DRAW")
icon_map[pid] = QIcon(Icons.get_pixmap(icon_name))
return icon_map

View file

@ -18,7 +18,6 @@ from warchron.model.exception import (
RequiresConfirmation, RequiresConfirmation,
) )
from warchron.model.tie_manager import TieResolver, TieContext from warchron.model.tie_manager import TieResolver, TieContext
from warchron.model.result_checker import ResultChecker
from warchron.model.pairing import Pairing from warchron.model.pairing import Pairing
from warchron.model.round import Round from warchron.model.round import Round
from warchron.model.war import War from warchron.model.war import War
@ -36,7 +35,7 @@ from warchron.controller.closure_workflow import (
RoundClosureWorkflow, RoundClosureWorkflow,
RoundPairingWorkflow, RoundPairingWorkflow,
) )
from warchron.controller.presenter import TiePresenter from warchron.controller.presenter import Presenter
from warchron.view.choice_dialog import ChoiceDialog from warchron.view.choice_dialog import ChoiceDialog
from warchron.view.battle_dialog import BattleDialog from warchron.view.battle_dialog import BattleDialog
from warchron.view.tie_dialog import TieDialog from warchron.view.tie_dialog import TieDialog
@ -47,7 +46,6 @@ class RoundController:
self.app = app self.app = app
def _fill_round_details(self, round_id: str) -> None: def _fill_round_details(self, round_id: str) -> None:
# self.app.view.clear_round_page()
rnd = self.app.model.get_round(round_id) rnd = self.app.model.get_round(round_id)
camp = self.app.model.get_campaign_by_round(round_id) camp = self.app.model.get_campaign_by_round(round_id)
war = self.app.model.get_war_by_round(round_id) war = self.app.model.get_war_by_round(round_id)
@ -135,7 +133,6 @@ class RoundController:
player_1_name = self.app.model.get_participant_name( player_1_name = self.app.model.get_participant_name(
camp_part.war_participant_id camp_part.war_participant_id
) )
p1_id = battle.player_1_id
else: else:
player_1_name = "" player_1_name = ""
if battle.player_2_id: if battle.player_2_id:
@ -143,7 +140,6 @@ class RoundController:
player_2_name = self.app.model.get_participant_name( player_2_name = self.app.model.get_participant_name(
camp_part.war_participant_id camp_part.war_participant_id
) )
p2_id = battle.player_2_id
else: else:
player_2_name = "" player_2_name = ""
if battle.winner_id: if battle.winner_id:
@ -153,34 +149,9 @@ class RoundController:
) )
else: else:
winner_name = "" winner_name = ""
p1_icon = None p1_icon, p2_icon = Presenter.compute_battle_icons(
p2_icon = None war, round_id, battle.sector_id
# TODO use uniform draw/tie icon logic with choice, war, campaign... )
if battle.is_draw():
p1_icon = Icons.get(IconName.DRAW)
p2_icon = Icons.get(IconName.DRAW)
context = TieContext(
ContextType.BATTLE,
battle.sector_id,
[p1_id, p2_id],
)
if TieResolver.was_tie_broken_by_tokens(war, context):
effective_winner = ResultChecker.get_effective_winner_id(
war, ContextType.BATTLE, battle.sector_id, None
)
p1_war = None
if battle.player_1_id is not None:
p1_war = camp.campaign_to_war_part_id(battle.player_1_id)
pixmap = Icons.get_pixmap(IconName.TIEBREAK_TOKEN)
if effective_winner == p1_war:
p1_icon = QIcon(pixmap)
else:
p2_icon = QIcon(pixmap)
elif battle.winner_id:
if battle.winner_id == battle.player_1_id:
p1_icon = Icons.get(IconName.WIN)
elif battle.winner_id == battle.player_2_id:
p2_icon = Icons.get(IconName.WIN)
battles_for_display.append( battles_for_display.append(
BattleDTO( BattleDTO(
id=battle.sector_id, id=battle.sector_id,
@ -293,9 +264,7 @@ class RoundController:
campaign = war.get_campaign_by_round(ctx.context_id) campaign = war.get_campaign_by_round(ctx.context_id)
if campaign: if campaign:
round = war.get_round(ctx.context_id) round = war.get_round(ctx.context_id)
data = TiePresenter.build_dialog_data( data = Presenter.build_dialog_data(war, ctx, round=round, campaign=campaign)
war, ctx, round=round, campaign=campaign
)
dialog = TieDialog( dialog = TieDialog(
parent=self.app.view, parent=self.app.view,
players=players, players=players,

View file

@ -27,8 +27,8 @@ from warchron.model.objective import Objective
from warchron.model.tie_manager import TieContext, TieResolver from warchron.model.tie_manager import TieContext, TieResolver
from warchron.model.score_service import ScoreService from warchron.model.score_service import ScoreService
from warchron.controller.closure_workflow import WarClosureWorkflow from warchron.controller.closure_workflow import WarClosureWorkflow
from warchron.controller.ranking_icon import RankingIcon
from warchron.controller.presenter import TiePresenter from warchron.controller.presenter import Presenter
from warchron.view.war_dialog import WarDialog from warchron.view.war_dialog import WarDialog
from warchron.view.objective_dialog import ObjectiveDialog from warchron.view.objective_dialog import ObjectiveDialog
from warchron.view.war_participant_dialog import WarParticipantDialog from warchron.view.war_participant_dialog import WarParticipantDialog
@ -62,11 +62,11 @@ class WarController:
vp_icon_map: Dict[str, QIcon] = {} vp_icon_map: Dict[str, QIcon] = {}
objective_icon_maps: Dict[str, Dict[str, QIcon]] = {} objective_icon_maps: Dict[str, Dict[str, QIcon]] = {}
if war.is_over: if war.is_over:
vp_icon_map = RankingIcon.compute_icons( vp_icon_map = Presenter.compute_ranking_icons(
war, ContextType.WAR, war_id, scores war, ContextType.WAR, war_id, scores
) )
for obj in war.get_objectives_used_as_maj_or_min(): for obj in war.get_objectives_used_as_maj_or_min():
objective_icon_maps[obj.id] = RankingIcon.compute_icons( objective_icon_maps[obj.id] = Presenter.compute_ranking_icons(
war, war,
ContextType.WAR, ContextType.WAR,
war.id, war.id,
@ -171,7 +171,7 @@ class WarController:
for pid in active for pid in active
] ]
counters = [war.get_influence_tokens(pid) for pid in active] counters = [war.get_influence_tokens(pid) for pid in active]
data = TiePresenter.build_dialog_data( data = Presenter.build_dialog_data(
war, war,
ctx, ctx,
) )

View file

@ -16,7 +16,7 @@ from warchron.model.round import Round
from warchron.model.battle import Battle from warchron.model.battle import Battle
from warchron.model.score_service import ScoreService from warchron.model.score_service import ScoreService
from warchron.model.tie_manager import TieResolver, TieContext from warchron.model.tie_manager import TieResolver, TieContext
from warchron.model.war_event import TieResolved, InfluenceSpent from warchron.model.war_event import TieResolved
from warchron.model.score_service import ParticipantScore from warchron.model.score_service import ParticipantScore
ResolveTiesCallback = Callable[ ResolveTiesCallback = Callable[
@ -304,28 +304,6 @@ class Pairing:
return AllocationType.SECONDARY return AllocationType.SECONDARY
return AllocationType.FALLBACK return AllocationType.FALLBACK
@staticmethod
def participant_spent_token(
war: War,
round_id: str,
sector_id: str | None,
war_participant_id: str,
) -> bool:
if sector_id is None:
return False
for ev in war.events:
if not isinstance(ev, InfluenceSpent):
continue
if ev.context_type != ContextType.CHOICE:
continue
if ev.context_id != round_id:
continue
if ev.sector_id != sector_id:
continue
if ev.participant_id == war_participant_id:
return True
return False
@staticmethod @staticmethod
def get_round_allocation( def get_round_allocation(
war: War, war: War,
@ -338,14 +316,16 @@ class Pairing:
raise DomainError(f"No campaign found for round {round.id}") raise DomainError(f"No campaign found for round {round.id}")
war_pid = campaign.campaign_to_war_part_id(campaign_participant_id) war_pid = campaign.campaign_to_war_part_id(campaign_participant_id)
token_priority = Pairing.participant_spent_token( token_priority = TieResolver.participant_spent_token(
war, war,
ContextType.CHOICE,
round.id, round.id,
choice.priority_sector_id, choice.priority_sector_id,
war_pid, war_pid,
) )
token_secondary = Pairing.participant_spent_token( token_secondary = TieResolver.participant_spent_token(
war, war,
ContextType.CHOICE,
round.id, round.id,
choice.secondary_sector_id, choice.secondary_sector_id,
war_pid, war_pid,

View file

@ -5,6 +5,7 @@ from typing import Any, Dict, List, TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
from warchron.model.campaign import Campaign from warchron.model.campaign import Campaign
from warchron.model.war import War from warchron.model.war import War
from warchron.constants import ContextType
from warchron.model.exception import ( from warchron.model.exception import (
ForbiddenOperation, ForbiddenOperation,
DomainError, DomainError,
@ -192,7 +193,7 @@ class Round:
victory_condition: str | None, victory_condition: str | None,
comment: str | None, comment: str | None,
) -> None: ) -> None:
from warchron.model.pairing import Pairing from warchron.model.tie_manager import TieResolver
if self.is_over: if self.is_over:
raise ForbiddenOperation("Can't update battle in a closed round.") raise ForbiddenOperation("Can't update battle in a closed round.")
@ -226,8 +227,9 @@ class Round:
if ( if (
player player
and self.has_choice_with_participant(player) and self.has_choice_with_participant(player)
and Pairing.participant_spent_token( and TieResolver.participant_spent_token(
self.war, self.war,
ContextType.CHOICE,
self.id, self.id,
sector_id, sector_id,
self.campaign.campaign_to_war_part_id(player), self.campaign.campaign_to_war_part_id(player),

View file

@ -486,3 +486,26 @@ class TieResolver:
continue continue
return True return True
return False return False
@staticmethod
def participant_spent_token(
war: War,
context_type: ContextType,
context_id: str,
sector_id: str | None,
war_participant_id: str,
) -> bool:
if context_type == ContextType.CHOICE and sector_id is None:
return False
for ev in war.events:
if not isinstance(ev, InfluenceSpent):
continue
if ev.context_type != context_type:
continue
if ev.context_id != context_id:
continue
if ev.sector_id != sector_id:
continue
if ev.participant_id == war_participant_id:
return True
return False

View file

@ -599,12 +599,6 @@ class View(QtWidgets.QMainWindow, Ui_MainWindow):
def show_round_details(self, *, index: int | None) -> None: def show_round_details(self, *, index: int | None) -> None:
self.roundNb.setText(f"Round {index}") self.roundNb.setText(f"Round {index}")
def clear_round_page(self) -> None:
choices_table = self.choicesTable
choices_table.clearContents()
battles_table = self.battlesTable
battles_table.clearContents()
def display_round_choices(self, participants: List[ChoiceDTO]) -> None: def display_round_choices(self, participants: List[ChoiceDTO]) -> None:
table = self.choicesTable table = self.choicesTable
table.setSortingEnabled(False) table.setSortingEnabled(False)