From 0bfe27e0d316f841ca362aa56b5faad2f5cd9630 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20R=C3=A9aux?= Date: Mon, 23 Feb 2026 17:36:28 +0100 Subject: [PATCH 1/2] display effective ranking in campaign participants table --- src/warchron/constants.py | 62 ++++++++++++++++++ .../controller/campaign_controller.py | 58 +++++++++++++++- src/warchron/controller/dtos.py | 7 +- src/warchron/model/closure_service.py | 3 +- src/warchron/model/result_checker.py | 46 +++++++++++++ src/warchron/model/score_service.py | 3 +- src/warchron/view/resources/medal-bronze.png | Bin 0 -> 824 bytes src/warchron/view/resources/medal-silver.png | Bin 0 -> 816 bytes src/warchron/view/resources/medal.png | Bin 0 -> 825 bytes src/warchron/view/resources/ribbon.png | Bin 0 -> 1625 bytes src/warchron/view/resources/trophy-bronze.png | Bin 0 -> 810 bytes src/warchron/view/resources/trophy-silver.png | Bin 0 -> 841 bytes src/warchron/view/view.py | 5 +- 13 files changed, 176 insertions(+), 8 deletions(-) create mode 100644 src/warchron/view/resources/medal-bronze.png create mode 100644 src/warchron/view/resources/medal-silver.png create mode 100644 src/warchron/view/resources/medal.png create mode 100644 src/warchron/view/resources/ribbon.png create mode 100644 src/warchron/view/resources/trophy-bronze.png create mode 100644 src/warchron/view/resources/trophy-silver.png 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 0000000000000000000000000000000000000000..3f3ff9925fb60ff878b9c5c68a512078ffa405f8 GIT binary patch literal 824 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 AssI20 literal 0 HcmV?d00001 diff --git a/src/warchron/view/resources/trophy-bronze.png b/src/warchron/view/resources/trophy-bronze.png new file mode 100644 index 0000000000000000000000000000000000000000..fae6fe516ceb85f59a7110c56e7087f615aa8091 GIT binary patch literal 810 zcmV+_1J(SAP)zyl_-hJ-|xLleNrQaUYTT0$2n zrFI#HcKm%x2_eBa$;(W>-+S+y-^R^cg%LnE;Tpk&|9i58S;DZu#8oXcge$^adAkc}^`H^==E2n7`#p6hqoKfWIfOY6Zj zvw!vW^`(3|jZ|tI$(=1!SO9@AG%(>~ad)lV^5}6}`_@`yncvRBDHu$qs1!=-b=+kG z*`M~`EXFwT&E2G+6HI@!J2|NNOh#_-))}JGMt@O-a+-nJ$SFLB{>o~bLCz^M(+XJy zld~gZt+yWZGzJ!ZWznC~?r8>2x8scKtgXAj>vYafi$SKT-%G!_a-_gFi- zdhWJfXl<%Ge*1zi5}o?Gvc%d)X?)i3SYTq|IEV6IP=WK4!{Y<_yv%tTJ(Vo5ycCJ~ zW&%IxqURhSlEKl#pUhKLA?CBWzM0Xnjndit)QXhPDSa$H{LBD*5D9! zI?%pshAAM5{JGj%xSUSd>~<6i1=@!l#1jbwLm@tufr+@)q+t`AWMY;^Ia}rLG5Dij oeE;0z*a7_XaviSp-fsa00N>>#&VK5RjQ{`u07*qoM6N<$f<63o%>V!Z literal 0 HcmV?d00001 diff --git a/src/warchron/view/resources/trophy-silver.png b/src/warchron/view/resources/trophy-silver.png new file mode 100644 index 0000000000000000000000000000000000000000..6a65909d94ce40bb255c75702ce57a53a8702a63 GIT binary patch literal 841 zcmV-P1GfB$P)q$gGRCwBqQ(Z_?VHkdQ&i*$&+nhFM z`Bx!@i;+@BbLgUr5kVxD-4tGkUD=H!1YsB6lwAZNm4a6Vky2EWg$W|o*x?-57#7ZK z7g3rM=iE=5XXl*WZ@O+G>A=IzdEe)GzW3*wqP@Lc4FHXZg9r`$-;+fwA~+=xyWQ@~ zhz^RPm~=V~cDo%Ci3G&szaSRd0*l28R%;3Pd>^5*vJ%v4Rh(sKpU%y_2W2jotMhm~ z9bH{rZ&;T7M(jhO(DmizWfZ4ib5jJGR)S8ah0e}%cSydWp=san@RN?2nYVr_8jYGX z8V%$3`$T3$1{Nd zH$iK)+V334rNZH`go8Zda=ARErKRe>4Gds1nH>ycqDDsUPfbp~9K)if6=h{*NXmjB z2sK#ZF$~rX3=EugI-QM$Ay!oEgY#`?G zeSzos5_}9cTZNre!1F$SXlU?VI-Poe27X-k22InTRN^fl6)+3~dc7WqBMBmGNV+vX z{z@JleIaA+Hi?lO;Ses-5Umn7kgwpBU4;EbxN}N|djVL%cG89EW4SWUYUN5*3`9wS zrGy02KP13A1QapIWHQjYM}Qim1P2(ItP4lrtSuNg97ikhWEaU14&f3F(JBh0yC(oo zheKH9BmC4KA7mRuk Date: Mon, 23 Feb 2026 19:28:13 +0100 Subject: [PATCH 2/2] fix icon mapping in campaign ranking --- src/warchron/constants.py | 68 +++++++++++++++---- .../controller/campaign_controller.py | 58 +++++++--------- src/warchron/model/result_checker.py | 25 ++++--- src/warchron/model/tie_manager.py | 18 +++++ src/warchron/view/view.py | 2 +- 5 files changed, 113 insertions(+), 58 deletions(-) diff --git a/src/warchron/constants.py b/src/warchron/constants.py index 948a97b..ead7600 100644 --- a/src/warchron/constants.py +++ b/src/warchron/constants.py @@ -50,13 +50,25 @@ class IconName(str, Enum): NP3RD = "np3rd" TIEBREAK_TOKEN = auto() VP1STDRAW = auto() - VP1STTIEBREAK = auto() + VP1STBREAK = auto() + VP1STTIEDRAW = auto() VP2NDDRAW = auto() - VP2NDTIEBREAK = auto() + VP2NDBREAK = auto() + VP2NDTIEDRAW = auto() VP3RDDRAW = auto() - VP3RDTIEBREAK = auto() + VP3RDBREAK = auto() + VP3RDTIEDRAW = auto() VPNTHDRAW = auto() - VPNTHTIEBREAK = auto() + VPNTHBREAK = auto() + VPNTHTIEDRAW = auto() + + +RANK_TO_ICON = { + 1: IconName.VP1ST, + 2: IconName.VP2ND, + 3: IconName.VP3RD, + 4: IconName.VPNTH, +} class Icons: @@ -119,41 +131,65 @@ class Icons: cls.get_pixmap(IconName.VP1ST), cls.get_pixmap(IconName.DRAW), ) - elif name == IconName.VP1STTIEBREAK: + elif name == IconName.VP1STBREAK: pix = cls._compose( cls.get_pixmap(IconName.VP1ST), cls.get_pixmap(IconName.TOKEN), ) + elif name == IconName.VP1STTIEDRAW: + pix = cls._compose( + cls.get_pixmap(IconName.VP1ST), + cls.get_pixmap(IconName.DRAW), + 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: + elif name == IconName.VP2NDBREAK: pix = cls._compose( cls.get_pixmap(IconName.VP2ND), cls.get_pixmap(IconName.TOKEN), ) + elif name == IconName.VP2NDTIEDRAW: + pix = cls._compose( + cls.get_pixmap(IconName.VP2ND), + cls.get_pixmap(IconName.DRAW), + 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: + elif name == IconName.VP3RDBREAK: pix = cls._compose( cls.get_pixmap(IconName.VP3RD), cls.get_pixmap(IconName.TOKEN), ) + elif name == IconName.VP3RDTIEDRAW: + pix = cls._compose( + cls.get_pixmap(IconName.VP3RD), + cls.get_pixmap(IconName.DRAW), + 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: + elif name == IconName.VPNTHBREAK: pix = cls._compose( cls.get_pixmap(IconName.VPNTH), cls.get_pixmap(IconName.TOKEN), ) + elif name == IconName.VPNTHTIEDRAW: + pix = cls._compose( + cls.get_pixmap(IconName.VPNTH), + cls.get_pixmap(IconName.DRAW), + cls.get_pixmap(IconName.TOKEN), + ) else: path = RESOURCES_DIR / cls._paths[name] pix = QPixmap(path.as_posix()) @@ -161,14 +197,20 @@ class Icons: return pix @staticmethod - def _compose(left: QPixmap, right: QPixmap) -> QPixmap: - w = left.width() + right.width() - h = max(left.height(), right.height()) + def _compose(*pixmaps: QPixmap) -> QPixmap: + if not pixmaps: + return QPixmap() + if len(pixmaps) == 1: + return pixmaps[0] + w = sum(p.width() for p in pixmaps) + h = max(p.height() for p in pixmaps) result = QPixmap(w, h) result.fill(Qt.GlobalColor.transparent) painter = QPainter(result) - painter.drawPixmap(0, (h - left.height()) // 2, left) - painter.drawPixmap(left.width(), (h - right.height()) // 2, right) + x = 0 + for p in pixmaps: + painter.drawPixmap(x, (h - p.height()) // 2, p) + x += p.width() painter.end() return result diff --git a/src/warchron/controller/campaign_controller.py b/src/warchron/controller/campaign_controller.py index dcf4d34..89805db 100644 --- a/src/warchron/controller/campaign_controller.py +++ b/src/warchron/controller/campaign_controller.py @@ -3,7 +3,14 @@ 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, Icons, IconName +from warchron.constants import ( + RefreshScope, + ContextType, + ItemType, + Icons, + IconName, + RANK_TO_ICON, +) if TYPE_CHECKING: from warchron.controller.app_controller import AppController @@ -45,40 +52,25 @@ class CampaignController: 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, + for rank, group, token_map in ranking: + base_icon = RANK_TO_ICON.get(rank, IconName.VPNTH) + tie_id = f"{campaign.id}:score:{scores[group[0]].victory_points}" + tie_resolved = TieResolver.is_tie_resolved( + 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 + spent = token_map.get(pid, 0) + if not tie_resolved and spent == 0: + icon_name = getattr(IconName, f"{base_icon.name}DRAW") + elif tie_resolved and spent == 0 and len(group) > 1: + icon_name = getattr(IconName, f"{base_icon.name}DRAW") + elif tie_resolved and spent > 0 and len(group) == 1: + icon_name = getattr(IconName, f"{base_icon.name}BREAK") + elif tie_resolved and spent > 0 and len(group) > 1: + icon_name = getattr(IconName, f"{base_icon.name}TIEDRAW") + else: + icon_name = base_icon + icon_map[pid] = QIcon(Icons.get_pixmap(icon_name)) return icon_map def _fill_campaign_details(self, campaign_id: str) -> None: diff --git a/src/warchron/model/result_checker.py b/src/warchron/model/result_checker.py index 5556dc7..978b0ce 100644 --- a/src/warchron/model/result_checker.py +++ b/src/warchron/model/result_checker.py @@ -37,24 +37,23 @@ class ResultChecker: context_type: ContextType, context_id: str, scores: Dict[str, ParticipantScore], - ) -> List[Tuple[int, List[str]]]: + ) -> List[Tuple[int, List[str], Dict[str, int]]]: 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]]] = [] + ranking: List[Tuple[int, List[str], Dict[str, int]]] = [] 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)) + # no tie + if len(participants) == 1 or not TieResolver.is_tie_resolved( + war, context_type, tie_id + ): + ranking.append( + (current_rank, participants, {pid: 0 for pid in participants}) + ) current_rank += 1 continue # apply token ranking @@ -64,7 +63,11 @@ class ResultChecker: tie_id, participants, ) + tokens_spent = TieResolver.tokens_spent_map( + war, context_type, tie_id, participants + ) for group in groups: - ranking.append((current_rank, group)) + group_tokens = {pid: tokens_spent[pid] for pid in group} + ranking.append((current_rank, group, group_tokens)) current_rank += 1 return ranking diff --git a/src/warchron/model/tie_manager.py b/src/warchron/model/tie_manager.py index fd678d7..12b291f 100644 --- a/src/warchron/model/tie_manager.py +++ b/src/warchron/model/tie_manager.py @@ -131,6 +131,24 @@ class TieResolver: groups[-1].append(pid) return groups + @staticmethod + def tokens_spent_map( + war: War, + context_type: ContextType, + context_id: str, + participants: List[str], + ) -> Dict[str, int]: + 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 + return spent + @staticmethod def get_active_participants( war: War, diff --git a/src/warchron/view/view.py b/src/warchron/view/view.py index a2c5cf5..fd65300 100644 --- a/src/warchron/view/view.py +++ b/src/warchron/view/view.py @@ -475,7 +475,7 @@ class View(QtWidgets.QMainWindow, Ui_MainWindow): table.setColumnCount(len(headers)) table.setHorizontalHeaderLabels(headers) table.setRowCount(len(participants)) - table.setIconSize(QSize(32, 16)) + table.setIconSize(QSize(48, 16)) for row, part in enumerate(participants): name_item = QtWidgets.QTableWidgetItem(part.player_name) if part.rank_icon: