display effective ranking in campaign participants table

This commit is contained in:
Maxime Réaux 2026-02-23 17:36:28 +01:00
parent 60992c22df
commit 0bfe27e0d3
13 changed files with 176 additions and 8 deletions

View file

@ -41,7 +41,22 @@ class IconName(str, Enum):
WARCHRON = "warchron" WARCHRON = "warchron"
TOKEN = "token" TOKEN = "token"
TOKENS = "tokens" TOKENS = "tokens"
VP1ST = "vp1st"
VP2ND = "vp2nd"
VP3RD = "vp3rd"
VPNTH = "vpnth"
NP1ST = "np1st"
NP2ND = "np2nd"
NP3RD = "np3rd"
TIEBREAK_TOKEN = auto() TIEBREAK_TOKEN = auto()
VP1STDRAW = auto()
VP1STTIEBREAK = auto()
VP2NDDRAW = auto()
VP2NDTIEBREAK = auto()
VP3RDDRAW = auto()
VP3RDTIEBREAK = auto()
VPNTHDRAW = auto()
VPNTHTIEBREAK = auto()
class Icons: class Icons:
@ -74,6 +89,13 @@ class Icons:
IconName.WARCHRON: "warchron_logo_background.png", IconName.WARCHRON: "warchron_logo_background.png",
IconName.TOKEN: "point.png", IconName.TOKEN: "point.png",
IconName.TOKENS: "points.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 @classmethod
@ -92,6 +114,46 @@ class Icons:
cls.get_pixmap(IconName.TIEBREAK), cls.get_pixmap(IconName.TIEBREAK),
cls.get_pixmap(IconName.TOKEN), 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: else:
path = RESOURCES_DIR / cls._paths[name] path = RESOURCES_DIR / cls._paths[name]
pix = QPixmap(path.as_posix()) pix = QPixmap(path.as_posix())

View file

@ -1,8 +1,9 @@
from typing import List, Dict, TYPE_CHECKING from typing import List, Dict, TYPE_CHECKING
from PyQt6.QtWidgets import QMessageBox, QDialog 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: if TYPE_CHECKING:
from warchron.controller.app_controller import AppController 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.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.model.result_checker import ResultChecker
from warchron.controller.closure_workflow import CampaignClosureWorkflow
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
from warchron.controller.closure_workflow import CampaignClosureWorkflow
from warchron.view.tie_dialog import TieDialog from warchron.view.tie_dialog import TieDialog
@ -31,6 +33,54 @@ class CampaignController:
def __init__(self, app: "AppController"): def __init__(self, app: "AppController"):
self.app = app 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: def _fill_campaign_details(self, campaign_id: str) -> None:
camp = self.app.model.get_campaign(campaign_id) camp = self.app.model.get_campaign(campaign_id)
self.app.view.show_campaign_details(name=camp.name, month=camp.month) 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) self.app.view.display_campaign_sectors(sectors_for_display)
scores = ScoreService.compute_scores(war, ContextType.CAMPAIGN, campaign_id) scores = ScoreService.compute_scores(war, ContextType.CAMPAIGN, campaign_id)
rows: List[CampaignParticipantScoreDTO] = [] 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(): for camp_part in camp.get_all_campaign_participants():
war_part_id = camp_part.war_participant_id war_part_id = camp_part.war_participant_id
war_part = war.get_war_participant(war_part_id) war_part = war.get_war_participant(war_part_id)
@ -66,6 +119,7 @@ class CampaignController:
theme=camp_part.theme or "", theme=camp_part.theme or "",
victory_points=score.victory_points, victory_points=score.victory_points,
narrative_points=dict(score.narrative_points), narrative_points=dict(score.narrative_points),
rank_icon=icon_map.get(war_part_id),
) )
) )
objectives = [ objectives = [

View file

@ -1,4 +1,4 @@
from typing import List from typing import List, Dict
from dataclasses import dataclass from dataclasses import dataclass
from PyQt6.QtGui import QIcon from PyQt6.QtGui import QIcon
@ -112,7 +112,7 @@ class ParticipantScoreDTO:
participant_id: str participant_id: str
player_name: str player_name: str
victory_points: int victory_points: int
narrative_points: dict[str, int] narrative_points: Dict[str, int]
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@ -123,4 +123,5 @@ class CampaignParticipantScoreDTO:
leader: str leader: str
theme: str theme: str
victory_points: int victory_points: int
narrative_points: dict[str, int] narrative_points: Dict[str, int]
rank_icon: QIcon | None = None

View file

@ -3,7 +3,6 @@ from typing import List
from warchron.constants import ContextType from warchron.constants import ContextType
from warchron.model.exception import ForbiddenOperation from warchron.model.exception import ForbiddenOperation
from warchron.model.result_checker import ResultChecker
from warchron.model.war_event import InfluenceGained from warchron.model.war_event import InfluenceGained
from warchron.model.war import War from warchron.model.war import War
from warchron.model.campaign import Campaign from warchron.model.campaign import Campaign
@ -26,6 +25,8 @@ class ClosureService:
@staticmethod @staticmethod
def apply_battle_outcomes(war: War, campaign: Campaign, battle: Battle) -> None: def apply_battle_outcomes(war: War, campaign: Campaign, battle: Battle) -> None:
from warchron.model.result_checker import ResultChecker
already_granted = any( already_granted = any(
isinstance(e, InfluenceGained) isinstance(e, InfluenceGained)
and e.context_id == f"battle:{battle.sector_id}" and e.context_id == f"battle:{battle.sector_id}"

View file

@ -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.constants import ContextType
from warchron.model.war import War from warchron.model.war import War
from warchron.model.war_event import TieResolved 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: class ResultChecker:
@ -22,3 +30,41 @@ class ResultChecker:
return ev.participant_id # None if confirmed draw return ev.participant_id # None if confirmed draw
return None 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

View file

@ -1,7 +1,6 @@
from typing import Dict, Iterator from typing import Dict, Iterator
from dataclasses import dataclass, field from dataclasses import dataclass, field
from warchron.model.result_checker import ResultChecker
from warchron.constants import ContextType from warchron.constants import ContextType
from warchron.model.war import War from warchron.model.war import War
from warchron.model.battle import Battle from warchron.model.battle import Battle
@ -44,6 +43,8 @@ class ScoreService:
def compute_scores( def compute_scores(
war: War, context_type: ContextType, context_id: str war: War, context_type: ContextType, context_id: str
) -> Dict[str, ParticipantScore]: ) -> Dict[str, ParticipantScore]:
from warchron.model.result_checker import ResultChecker
scores = { scores = {
pid: ParticipantScore( pid: ParticipantScore(
narrative_points={obj_id: 0 for obj_id in war.objectives} narrative_points={obj_id: 0 for obj_id in war.objectives}

Binary file not shown.

After

Width:  |  Height:  |  Size: 824 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 816 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 825 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 810 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 841 B

View file

@ -475,8 +475,11 @@ class View(QtWidgets.QMainWindow, Ui_MainWindow):
table.setColumnCount(len(headers)) table.setColumnCount(len(headers))
table.setHorizontalHeaderLabels(headers) table.setHorizontalHeaderLabels(headers)
table.setRowCount(len(participants)) table.setRowCount(len(participants))
table.setIconSize(QSize(32, 16))
for row, part in enumerate(participants): for row, part in enumerate(participants):
name_item = QtWidgets.QTableWidgetItem(part.player_name) name_item = QtWidgets.QTableWidgetItem(part.player_name)
if part.rank_icon:
name_item.setIcon(part.rank_icon)
lead_item = QtWidgets.QTableWidgetItem(part.leader) lead_item = QtWidgets.QTableWidgetItem(part.leader)
theme_item = QtWidgets.QTableWidgetItem(part.theme) theme_item = QtWidgets.QTableWidgetItem(part.theme)
VP_item = QtWidgets.QTableWidgetItem(str(part.victory_points)) VP_item = QtWidgets.QTableWidgetItem(str(part.victory_points))
@ -554,7 +557,7 @@ class View(QtWidgets.QMainWindow, Ui_MainWindow):
table = self.battlesTable table = self.battlesTable
table.clearContents() table.clearContents()
table.setRowCount(len(sectors)) table.setRowCount(len(sectors))
self.battlesTable.setIconSize(QSize(32, 16)) table.setIconSize(QSize(32, 16))
for row, battle in enumerate(sectors): for row, battle in enumerate(sectors):
sector_item = QtWidgets.QTableWidgetItem(battle.sector_name) sector_item = QtWidgets.QTableWidgetItem(battle.sector_name)
if battle.state_icon: if battle.state_icon: