diff --git a/src/warchron/constants.py b/src/warchron/constants.py index a79e5fc..ead7600 100644 --- a/src/warchron/constants.py +++ b/src/warchron/constants.py @@ -41,7 +41,34 @@ 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() + VP1STBREAK = auto() + VP1STTIEDRAW = auto() + VP2NDDRAW = auto() + VP2NDBREAK = auto() + VP2NDTIEDRAW = auto() + VP3RDDRAW = auto() + VP3RDBREAK = auto() + VP3RDTIEDRAW = auto() + VPNTHDRAW = auto() + VPNTHBREAK = auto() + VPNTHTIEDRAW = auto() + + +RANK_TO_ICON = { + 1: IconName.VP1ST, + 2: IconName.VP2ND, + 3: IconName.VP3RD, + 4: IconName.VPNTH, +} class Icons: @@ -74,6 +101,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 +126,70 @@ 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.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.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.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.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()) @@ -99,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 9447058..89805db 100644 --- a/src/warchron/controller/campaign_controller.py +++ b/src/warchron/controller/campaign_controller.py @@ -1,8 +1,16 @@ 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, + RANK_TO_ICON, +) if TYPE_CHECKING: from warchron.controller.app_controller import AppController @@ -20,10 +28,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 +40,39 @@ 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, 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 + ) + for pid in group: + 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: camp = self.app.model.get_campaign(campaign_id) self.app.view.show_campaign_details(name=camp.name, month=camp.month) @@ -52,6 +94,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 +111,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..978b0ce 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,44 @@ 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], 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], Dict[str, int]]] = [] + current_rank = 1 + for vp in sorted_vps: + participants = vp_buckets[vp] + tie_id = f"{context_id}:score:{vp}" + # 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 + groups = TieResolver.rank_by_tokens( + war, + context_type, + tie_id, + participants, + ) + tokens_spent = TieResolver.tokens_spent_map( + war, context_type, tie_id, participants + ) + for group in groups: + 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/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/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/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..fd65300 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(48, 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: