diff --git a/src/warchron/constants.py b/src/warchron/constants.py index a79e5fc..948a97b 100644 --- a/src/warchron/constants.py +++ b/src/warchron/constants.py @@ -41,7 +41,22 @@ class IconName(str, Enum): WARCHRON = "warchron" TOKEN = "token" TOKENS = "tokens" + VP1ST = "vp1st" + VP2ND = "vp2nd" + VP3RD = "vp3rd" + VPNTH = "vpnth" + NP1ST = "np1st" + NP2ND = "np2nd" + NP3RD = "np3rd" TIEBREAK_TOKEN = auto() + VP1STDRAW = auto() + VP1STTIEBREAK = auto() + VP2NDDRAW = auto() + VP2NDTIEBREAK = auto() + VP3RDDRAW = auto() + VP3RDTIEBREAK = auto() + VPNTHDRAW = auto() + VPNTHTIEBREAK = auto() class Icons: @@ -74,6 +89,13 @@ class Icons: IconName.WARCHRON: "warchron_logo_background.png", IconName.TOKEN: "point.png", IconName.TOKENS: "points.png", + IconName.VP1ST: "trophy.png", + IconName.VP2ND: "trophy-silver.png", + IconName.VP3RD: "trophy-bronze.png", + IconName.VPNTH: "ribbon.png", + IconName.NP1ST: "medal.png", + IconName.NP2ND: "medal-silver.png", + IconName.NP3RD: "medal-bronze.png", } @classmethod @@ -92,6 +114,46 @@ class Icons: cls.get_pixmap(IconName.TIEBREAK), cls.get_pixmap(IconName.TOKEN), ) + elif name == IconName.VP1STDRAW: + pix = cls._compose( + cls.get_pixmap(IconName.VP1ST), + cls.get_pixmap(IconName.DRAW), + ) + elif name == IconName.VP1STTIEBREAK: + pix = cls._compose( + cls.get_pixmap(IconName.VP1ST), + cls.get_pixmap(IconName.TOKEN), + ) + elif name == IconName.VP2NDDRAW: + pix = cls._compose( + cls.get_pixmap(IconName.VP2ND), + cls.get_pixmap(IconName.DRAW), + ) + elif name == IconName.VP2NDTIEBREAK: + pix = cls._compose( + cls.get_pixmap(IconName.VP2ND), + cls.get_pixmap(IconName.TOKEN), + ) + elif name == IconName.VP3RDDRAW: + pix = cls._compose( + cls.get_pixmap(IconName.VP3RD), + cls.get_pixmap(IconName.DRAW), + ) + elif name == IconName.VP3RDTIEBREAK: + pix = cls._compose( + cls.get_pixmap(IconName.VP3RD), + cls.get_pixmap(IconName.TOKEN), + ) + elif name == IconName.VPNTHDRAW: + pix = cls._compose( + cls.get_pixmap(IconName.VPNTH), + cls.get_pixmap(IconName.DRAW), + ) + elif name == IconName.VPNTHTIEBREAK: + pix = cls._compose( + cls.get_pixmap(IconName.VPNTH), + cls.get_pixmap(IconName.TOKEN), + ) else: path = RESOURCES_DIR / cls._paths[name] pix = QPixmap(path.as_posix()) diff --git a/src/warchron/controller/campaign_controller.py b/src/warchron/controller/campaign_controller.py index 9447058..dcf4d34 100644 --- a/src/warchron/controller/campaign_controller.py +++ b/src/warchron/controller/campaign_controller.py @@ -1,8 +1,9 @@ from typing import List, Dict, TYPE_CHECKING from PyQt6.QtWidgets import QMessageBox, QDialog +from PyQt6.QtGui import QIcon -from warchron.constants import RefreshScope, ContextType, ItemType +from warchron.constants import RefreshScope, ContextType, ItemType, Icons, IconName if TYPE_CHECKING: from warchron.controller.app_controller import AppController @@ -20,10 +21,11 @@ from warchron.model.campaign_participant import CampaignParticipant from warchron.model.sector import Sector from warchron.model.tie_manager import TieContext, TieResolver from warchron.model.score_service import ScoreService +from warchron.model.result_checker import ResultChecker +from warchron.controller.closure_workflow import CampaignClosureWorkflow 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 @@ -31,6 +33,54 @@ class CampaignController: def __init__(self, app: "AppController"): self.app = app + def _compute_campaign_ranking_icons( + self, war: War, campaign: Campaign + ) -> Dict[str, QIcon]: + scores = ScoreService.compute_scores( + war, + ContextType.CAMPAIGN, + campaign.id, + ) + ranking = ResultChecker.get_effective_ranking( + war, ContextType.CAMPAIGN, campaign.id, scores + ) + icon_map = {} + for rank, group in ranking: + vp = scores[group[0]].victory_points + tie_id = f"{campaign.id}:score:{vp}" + is_tie = len(group) > 1 + broken = TieResolver.was_tie_broken_by_tokens( + war, + ContextType.CAMPAIGN, + tie_id, + ) + # choose icon name + if rank == 1: + base = IconName.VP1ST + draw = IconName.VP1STDRAW + tb = IconName.VP1STTIEBREAK + elif rank == 2: + base = IconName.VP2ND + draw = IconName.VP2NDDRAW + tb = IconName.VP2NDTIEBREAK + elif rank == 3: + base = IconName.VP3RD + draw = IconName.VP3RDDRAW + tb = IconName.VP3RDTIEBREAK + else: + base = IconName.VPNTH + draw = IconName.VPNTHDRAW + tb = IconName.VPNTHTIEBREAK + if not is_tie: + icon = Icons.get(base) + elif not broken: + icon = QIcon(Icons.get_pixmap(draw)) + else: + icon = QIcon(Icons.get_pixmap(tb)) + for pid in group: + icon_map[pid] = icon + return icon_map + def _fill_campaign_details(self, campaign_id: str) -> None: camp = self.app.model.get_campaign(campaign_id) self.app.view.show_campaign_details(name=camp.name, month=camp.month) @@ -52,6 +102,9 @@ class CampaignController: self.app.view.display_campaign_sectors(sectors_for_display) scores = ScoreService.compute_scores(war, ContextType.CAMPAIGN, campaign_id) rows: List[CampaignParticipantScoreDTO] = [] + icon_map = {} + if camp.is_over: + icon_map = self._compute_campaign_ranking_icons(war, camp) 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) @@ -66,6 +119,7 @@ class CampaignController: theme=camp_part.theme or "", victory_points=score.victory_points, narrative_points=dict(score.narrative_points), + rank_icon=icon_map.get(war_part_id), ) ) objectives = [ diff --git a/src/warchron/controller/dtos.py b/src/warchron/controller/dtos.py index 7d52aaf..4668ff7 100644 --- a/src/warchron/controller/dtos.py +++ b/src/warchron/controller/dtos.py @@ -1,4 +1,4 @@ -from typing import List +from typing import List, Dict from dataclasses import dataclass from PyQt6.QtGui import QIcon @@ -112,7 +112,7 @@ class ParticipantScoreDTO: participant_id: str player_name: str victory_points: int - narrative_points: dict[str, int] + narrative_points: Dict[str, int] @dataclass(frozen=True, slots=True) @@ -123,4 +123,5 @@ class CampaignParticipantScoreDTO: leader: str theme: str victory_points: int - narrative_points: dict[str, int] + narrative_points: Dict[str, int] + rank_icon: QIcon | None = None diff --git a/src/warchron/model/closure_service.py b/src/warchron/model/closure_service.py index 3816e5c..6f62997 100644 --- a/src/warchron/model/closure_service.py +++ b/src/warchron/model/closure_service.py @@ -3,7 +3,6 @@ from typing import List from warchron.constants import ContextType from warchron.model.exception import ForbiddenOperation -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 @@ -26,6 +25,8 @@ class ClosureService: @staticmethod def apply_battle_outcomes(war: War, campaign: Campaign, battle: Battle) -> None: + from warchron.model.result_checker import ResultChecker + already_granted = any( isinstance(e, InfluenceGained) and e.context_id == f"battle:{battle.sector_id}" diff --git a/src/warchron/model/result_checker.py b/src/warchron/model/result_checker.py index de07440..5556dc7 100644 --- a/src/warchron/model/result_checker.py +++ b/src/warchron/model/result_checker.py @@ -1,6 +1,14 @@ +from __future__ import annotations +from typing import List, Tuple, Dict, TYPE_CHECKING +from collections import defaultdict + from warchron.constants import ContextType from warchron.model.war import War from warchron.model.war_event import TieResolved +from warchron.model.tie_manager import TieResolver + +if TYPE_CHECKING: + from warchron.model.score_service import ParticipantScore class ResultChecker: @@ -22,3 +30,41 @@ class ResultChecker: return ev.participant_id # None if confirmed draw return None + + @staticmethod + def get_effective_ranking( + war: War, + context_type: ContextType, + context_id: str, + scores: Dict[str, ParticipantScore], + ) -> List[Tuple[int, List[str]]]: + vp_buckets: Dict[int, List[str]] = defaultdict(list) + for pid, score in scores.items(): + vp_buckets[score.victory_points].append(pid) + sorted_vps = sorted(vp_buckets.keys(), reverse=True) + ranking: List[Tuple[int, List[str]]] = [] + current_rank = 1 + for vp in sorted_vps: + participants = vp_buckets[vp] + # no tie + if len(participants) == 1: + ranking.append((current_rank, participants)) + current_rank += 1 + continue + tie_id = f"{context_id}:score:{vp}" + # tie unresolved → shared rank (theoretically impossible) + if not TieResolver.is_tie_resolved(war, context_type, tie_id): + ranking.append((current_rank, participants)) + current_rank += 1 + continue + # apply token ranking + groups = TieResolver.rank_by_tokens( + war, + context_type, + tie_id, + participants, + ) + for group in groups: + ranking.append((current_rank, group)) + current_rank += 1 + return ranking diff --git a/src/warchron/model/score_service.py b/src/warchron/model/score_service.py index 16da0ab..0fb98b5 100644 --- a/src/warchron/model/score_service.py +++ b/src/warchron/model/score_service.py @@ -1,7 +1,6 @@ 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.war import War from warchron.model.battle import Battle @@ -44,6 +43,8 @@ class ScoreService: def compute_scores( war: War, context_type: ContextType, context_id: str ) -> Dict[str, ParticipantScore]: + from warchron.model.result_checker import ResultChecker + scores = { pid: ParticipantScore( narrative_points={obj_id: 0 for obj_id in war.objectives} diff --git a/src/warchron/view/resources/medal-bronze.png b/src/warchron/view/resources/medal-bronze.png new file mode 100644 index 0000000..3f3ff99 Binary files /dev/null and b/src/warchron/view/resources/medal-bronze.png differ diff --git a/src/warchron/view/resources/medal-silver.png b/src/warchron/view/resources/medal-silver.png new file mode 100644 index 0000000..c578e3a Binary files /dev/null and b/src/warchron/view/resources/medal-silver.png differ diff --git a/src/warchron/view/resources/medal.png b/src/warchron/view/resources/medal.png new file mode 100644 index 0000000..2f82d8b Binary files /dev/null and b/src/warchron/view/resources/medal.png differ diff --git a/src/warchron/view/resources/ribbon.png b/src/warchron/view/resources/ribbon.png new file mode 100644 index 0000000..2bdafcd Binary files /dev/null and b/src/warchron/view/resources/ribbon.png differ diff --git a/src/warchron/view/resources/trophy-bronze.png b/src/warchron/view/resources/trophy-bronze.png new file mode 100644 index 0000000..fae6fe5 Binary files /dev/null and b/src/warchron/view/resources/trophy-bronze.png differ diff --git a/src/warchron/view/resources/trophy-silver.png b/src/warchron/view/resources/trophy-silver.png new file mode 100644 index 0000000..6a65909 Binary files /dev/null and b/src/warchron/view/resources/trophy-silver.png differ diff --git a/src/warchron/view/view.py b/src/warchron/view/view.py index aae2527..a2c5cf5 100644 --- a/src/warchron/view/view.py +++ b/src/warchron/view/view.py @@ -475,8 +475,11 @@ class View(QtWidgets.QMainWindow, Ui_MainWindow): table.setColumnCount(len(headers)) table.setHorizontalHeaderLabels(headers) table.setRowCount(len(participants)) + table.setIconSize(QSize(32, 16)) for row, part in enumerate(participants): name_item = QtWidgets.QTableWidgetItem(part.player_name) + if part.rank_icon: + name_item.setIcon(part.rank_icon) lead_item = QtWidgets.QTableWidgetItem(part.leader) theme_item = QtWidgets.QTableWidgetItem(part.theme) VP_item = QtWidgets.QTableWidgetItem(str(part.victory_points)) @@ -554,7 +557,7 @@ class View(QtWidgets.QMainWindow, Ui_MainWindow): table = self.battlesTable table.clearContents() table.setRowCount(len(sectors)) - self.battlesTable.setIconSize(QSize(32, 16)) + table.setIconSize(QSize(32, 16)) for row, battle in enumerate(sectors): sector_item = QtWidgets.QTableWidgetItem(battle.sector_name) if battle.state_icon: