From a3144dc3c950abab1b8e6b0bf6970e14715dddeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20R=C3=A9aux?= Date: Tue, 24 Mar 2026 16:26:52 +0100 Subject: [PATCH 1/5] display tiebreak place --- src/warchron/controller/dtos.py | 5 -- src/warchron/controller/presenter.py | 82 +++++++++++++++++++++++---- src/warchron/model/pairing.py | 1 + src/warchron/view/ui/ui_tie_dialog.py | 2 +- src/warchron/view/ui/ui_tie_dialog.ui | 2 +- 5 files changed, 73 insertions(+), 19 deletions(-) diff --git a/src/warchron/controller/dtos.py b/src/warchron/controller/dtos.py index cc9b211..ddf777e 100644 --- a/src/warchron/controller/dtos.py +++ b/src/warchron/controller/dtos.py @@ -135,11 +135,6 @@ class WarParticipantScoreDTO: objective_icons: Dict[str, QIcon] = field(default_factory=dict) -@dataclass -class TieDialogData: - title: str - - @dataclass class WarSettingsDTO: major_value: int diff --git a/src/warchron/controller/presenter.py b/src/warchron/controller/presenter.py index d41a336..a7d8f44 100644 --- a/src/warchron/controller/presenter.py +++ b/src/warchron/controller/presenter.py @@ -1,4 +1,5 @@ from typing import Dict +from dataclasses import dataclass from PyQt6.QtGui import QIcon @@ -11,16 +12,20 @@ from warchron.constants import ( ScoreKind, ) -from warchron.controller.dtos import TieDialogData from warchron.model.tiebreaking import TieContext, TieBreaker from warchron.model.war import War from warchron.model.campaign import Campaign from warchron.model.round import Round -from warchron.model.scoring import ParticipantScore +from warchron.model.scoring import ParticipantScore, ScoreComputer from warchron.model.checking import ResultChecker from warchron.model.exception import DomainError +@dataclass +class TieDialogData: + title: str + + class Presenter: @staticmethod @@ -30,17 +35,16 @@ class Presenter: campaign: Campaign | None = None, round: Round | None = None, ) -> TieDialogData: - # TODO display Nth place - if ctx.context_type == ContextType.WAR: - if ctx.objective_id: + if ctx.context_type in (ContextType.WAR, ContextType.CAMPAIGN): + rank = Presenter._get_tie_rank(war, ctx) + rank_label = Presenter._build_rank_label(rank) + level = str(ctx.context_type).capitalize() + if ctx.objective_id is not None: obj = war.objectives[ctx.objective_id] - return TieDialogData(f"War objective tie — {obj.name}") - return TieDialogData("War tie") - if ctx.context_type == ContextType.CAMPAIGN: - if ctx.objective_id: - obj = war.objectives[ctx.objective_id] - return TieDialogData(f"Campaign objective tie — {obj.name}") - return TieDialogData("Campaign tie") + return TieDialogData( + f"{level} objective tie for {rank_label} — {obj.name}" + ) + return TieDialogData(f"{level} tie for {rank_label}") if ctx.context_type == ContextType.BATTLE: if campaign: sector = campaign.sectors[ctx.context_id] @@ -177,3 +181,57 @@ class Presenter: compute_icon(battle.player_1_id), compute_icon(battle.player_2_id), ) + + @staticmethod + def _ordinal(n: int) -> str: + if 10 <= n % 100 <= 20: + suffix = "th" + else: + suffix = {1: "st", 2: "nd", 3: "rd"}.get(n % 10, "th") + return f"{n}{suffix}" + + @staticmethod + def _build_rank_label(rank: int | None) -> str: + if rank is None: + return "" + return f"{Presenter._ordinal(rank)} place" + + @staticmethod + def _get_tie_rank( + war: War, + ctx: TieContext, + ) -> int | None: + from warchron.model.checking import ResultChecker + + scores = ScoreComputer.compute_scores( + war, + ctx.context_type, + ctx.context_id, + ) + if ctx.objective_id is None: + + def value_getter(score: ParticipantScore) -> int: + return score.victory_points + + score_kind = ScoreKind.VP + else: + obj_id = ctx.objective_id + + def value_getter(score: ParticipantScore) -> int: + return score.narrative_points.get(obj_id, 0) + + score_kind = ScoreKind.NP + ranking = ResultChecker.get_effective_ranking( + war, + ctx.context_type, + ctx.context_id, + score_kind, + scores, + value_getter, + ctx.objective_id, + ) + tied = set(ctx.participants) + for rank, group, _ in ranking: + if tied.intersection(group): + return rank + return None diff --git a/src/warchron/model/pairing.py b/src/warchron/model/pairing.py index 3c8efae..ef7dcd4 100644 --- a/src/warchron/model/pairing.py +++ b/src/warchron/model/pairing.py @@ -334,6 +334,7 @@ class Pairing: raise DomainError("Campaign not found") match_counts = Pairing.build_match_count(campaign) available_battles = round.get_battles_with_places() + # FIXME no error when all participants allocated if not available_battles: raise DomainError("No available battle remaining") occupancy = { diff --git a/src/warchron/view/ui/ui_tie_dialog.py b/src/warchron/view/ui/ui_tie_dialog.py index 0361373..f6af573 100644 --- a/src/warchron/view/ui/ui_tie_dialog.py +++ b/src/warchron/view/ui/ui_tie_dialog.py @@ -67,7 +67,7 @@ class Ui_tieDialog(object): def retranslateUi(self, tieDialog): _translate = QtCore.QCoreApplication.translate - tieDialog.setWindowTitle(_translate("tieDialog", "Tie")) + tieDialog.setWindowTitle(_translate("tieDialog", "Tie-break")) self.tieContext.setText(_translate("tieDialog", "Battle tie")) diff --git a/src/warchron/view/ui/ui_tie_dialog.ui b/src/warchron/view/ui/ui_tie_dialog.ui index 38e6744..69b9fef 100644 --- a/src/warchron/view/ui/ui_tie_dialog.ui +++ b/src/warchron/view/ui/ui_tie_dialog.ui @@ -14,7 +14,7 @@ - Tie + Tie-break From ae6c033bbeb5821d88d3bbfb19f51e5d9268e009 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20R=C3=A9aux?= Date: Wed, 25 Mar 2026 12:17:21 +0100 Subject: [PATCH 2/5] fix useless fallback at end of pairing --- src/warchron/model/pairing.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/warchron/model/pairing.py b/src/warchron/model/pairing.py index ef7dcd4..fc3cf71 100644 --- a/src/warchron/model/pairing.py +++ b/src/warchron/model/pairing.py @@ -329,12 +329,13 @@ class Pairing: round: Round, remaining: List[str], ) -> None: + if not remaining: + return campaign = war.get_campaign_by_round(round.id) if campaign is None: raise DomainError("Campaign not found") match_counts = Pairing.build_match_count(campaign) available_battles = round.get_battles_with_places() - # FIXME no error when all participants allocated if not available_battles: raise DomainError("No available battle remaining") occupancy = { From 69942a3cffb5350d243a4e556c842838da4cfef2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20R=C3=A9aux?= Date: Thu, 26 Mar 2026 11:21:17 +0100 Subject: [PATCH 3/5] allow closing round with incomplete battles --- src/warchron/controller/app_controller.py | 9 +++-- src/warchron/controller/round_controller.py | 41 ++++++++++++++++----- src/warchron/controller/war_controller.py | 3 +- src/warchron/controller/workflows.py | 6 ++- src/warchron/model/battle.py | 6 +++ src/warchron/model/closing.py | 18 ++++++--- src/warchron/model/exception.py | 2 +- src/warchron/model/pairing.py | 5 +-- src/warchron/model/round.py | 1 - src/warchron/model/tiebreaking.py | 5 ++- 10 files changed, 67 insertions(+), 29 deletions(-) diff --git a/src/warchron/controller/app_controller.py b/src/warchron/controller/app_controller.py index fd47b91..7522e08 100644 --- a/src/warchron/controller/app_controller.py +++ b/src/warchron/controller/app_controller.py @@ -235,7 +235,8 @@ class AppController: QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if reply == QMessageBox.StandardButton.Yes: - e.action() + if e.action: + e.action() else: return self.is_dirty = True @@ -290,7 +291,8 @@ class AppController: ) if reply == QMessageBox.StandardButton.Yes: try: - e.action() + if e.action: + e.action() except DomainError as inner: QMessageBox.warning( self.view, @@ -361,7 +363,8 @@ class AppController: ) if reply == QMessageBox.StandardButton.Yes: try: - e.action() + if e.action: + e.action() except DomainError as inner: QMessageBox.warning( self.view, diff --git a/src/warchron/controller/round_controller.py b/src/warchron/controller/round_controller.py index fb11b7b..e0612fe 100644 --- a/src/warchron/controller/round_controller.py +++ b/src/warchron/controller/round_controller.py @@ -186,14 +186,36 @@ class RoundController: camp = self.app.model.get_campaign_by_round(round_id) war = self.app.model.get_war_by_round(round_id) workflow = RoundClosureWorkflow(self.app) - try: - workflow.start(war, camp, rnd) - except DomainError as e: - QMessageBox.warning( - self.app.view, - "Closure forbidden", - str(e), - ) + confirmed = False + stop = False + while True: + try: + workflow.start(war, camp, rnd, confirmed) + break + except RequiresConfirmation as e: + reply = QMessageBox.question( + self.app.view, + "Confirm closing", + str(e), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if reply == QMessageBox.StandardButton.Yes: + if e.action: + e.action() + confirmed = True + continue + else: + stop = True + break + except DomainError as e: + QMessageBox.warning( + self.app.view, + "Closure forbidden", + str(e), + ) + stop = True + break + if stop: return self.app.is_dirty = True self.app.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) @@ -234,7 +256,8 @@ class RoundController: QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if reply == QMessageBox.StandardButton.Yes: - e.action() + if e.action: + e.action() else: return self.app.is_dirty = True diff --git a/src/warchron/controller/war_controller.py b/src/warchron/controller/war_controller.py index 4b7c3ef..eb6848e 100644 --- a/src/warchron/controller/war_controller.py +++ b/src/warchron/controller/war_controller.py @@ -221,7 +221,8 @@ class WarController: QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if reply == QMessageBox.StandardButton.Yes: - e.action() + if e.action: + e.action() else: return self.is_dirty = True diff --git a/src/warchron/controller/workflows.py b/src/warchron/controller/workflows.py index ea0a4b3..ce0cc19 100644 --- a/src/warchron/controller/workflows.py +++ b/src/warchron/controller/workflows.py @@ -18,8 +18,10 @@ class Workflow: class RoundClosureWorkflow(Workflow): - def start(self, war: War, campaign: Campaign, round: Round) -> None: - Closer.check_round_closable(round) + def start( + self, war: War, campaign: Campaign, round: Round, confirmed: bool = False + ) -> None: + Closer.check_round_closable(round, confirmed) ties = TieBreaker.find_battle_ties(war, round.id) while ties: for tie in ties: diff --git a/src/warchron/model/battle.py b/src/warchron/model/battle.py index 02a9e90..3a105ce 100644 --- a/src/warchron/model/battle.py +++ b/src/warchron/model/battle.py @@ -80,6 +80,12 @@ class Battle: def is_finished(self) -> bool: return self.winner_id is not None or self.is_draw() + def has_player(self) -> bool: + return self.player_1_id is not None or self.player_2_id is not None + + def is_complete(self) -> bool: + return self.player_1_id is not None and self.player_2_id is not None + def toDict(self) -> Dict[str, Any]: return { "sector_id": self.sector_id, diff --git a/src/warchron/model/closing.py b/src/warchron/model/closing.py index 0b70b39..7140ac8 100644 --- a/src/warchron/model/closing.py +++ b/src/warchron/model/closing.py @@ -1,7 +1,7 @@ from __future__ import annotations from warchron.constants import ContextType -from warchron.model.exception import ForbiddenOperation +from warchron.model.exception import ForbiddenOperation, RequiresConfirmation from warchron.model.war_event import InfluenceGained from warchron.model.war import War from warchron.model.campaign import Campaign @@ -14,13 +14,19 @@ class Closer: # Round methods @staticmethod - def check_round_closable(round: Round) -> None: + def check_round_closable(round: Round, confirmed: bool) -> None: if round.is_over: raise ForbiddenOperation("Round already closed") - if not round.all_battles_finished(): - raise ForbiddenOperation( - "All battles must be finished to close their round" - ) + if not confirmed: + if any(not bat.is_complete() for bat in round.battles.values()): + raise RequiresConfirmation( + "Battle(s) in this round miss player(s).\n" + "Do you want to continue?", + ) + if not round.all_battles_finished(): + raise ForbiddenOperation( + "All battles must be finished to close their round" + ) @staticmethod def apply_battle_outcomes(war: War, campaign: Campaign, battle: Battle) -> None: diff --git a/src/warchron/model/exception.py b/src/warchron/model/exception.py index 26194e3..5c27295 100644 --- a/src/warchron/model/exception.py +++ b/src/warchron/model/exception.py @@ -26,6 +26,6 @@ class DomainDecision(Exception): class RequiresConfirmation(DomainDecision): - def __init__(self, message: str, action: Callable[[], None]): + def __init__(self, message: str, action: Callable[[], None] | None = None): super().__init__(message) self.action = action diff --git a/src/warchron/model/pairing.py b/src/warchron/model/pairing.py index fc3cf71..09d9d58 100644 --- a/src/warchron/model/pairing.py +++ b/src/warchron/model/pairing.py @@ -63,10 +63,7 @@ class Pairing: bat.set_winner(None) war.revert_choice_ties(round.id) - if any( - bat.player_1_id is not None or bat.player_2_id is not None - for bat in round.battles.values() - ): + if any(bat.has_player() for bat in round.battles.values()): raise RequiresConfirmation( "Battle(s) already have player(s) assigned for this round.\n" "Battle players will be cleared.\n" diff --git a/src/warchron/model/round.py b/src/warchron/model/round.py index 4b1a630..ce4c73b 100644 --- a/src/warchron/model/round.py +++ b/src/warchron/model/round.py @@ -166,7 +166,6 @@ class Round: return any(b.is_finished() for b in self.battles.values()) def all_battles_finished(self) -> bool: - # TODO exception for participant alone return all( b.winner_id is not None or b.is_draw() for b in self.battles.values() ) diff --git a/src/warchron/model/tiebreaking.py b/src/warchron/model/tiebreaking.py index ed11ed0..a71767b 100644 --- a/src/warchron/model/tiebreaking.py +++ b/src/warchron/model/tiebreaking.py @@ -63,8 +63,9 @@ class TieBreaker: for battle in round.battles.values(): if campaign is None: raise DomainError("No campaign for this battle tie") - if battle.player_1_id is None or battle.player_2_id is None: - raise DomainError("Missing player(s) in this battle context.") + if not battle.is_complete(): + continue + assert battle.player_1_id is not None and battle.player_2_id is not None p1_id = campaign.campaign_to_war_part_id(battle.player_1_id) p2_id = campaign.campaign_to_war_part_id(battle.player_2_id) if not battle.is_draw(): From 261c64942d185d29101ef7c40dda533aaa7a030e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20R=C3=A9aux?= Date: Thu, 26 Mar 2026 15:42:40 +0100 Subject: [PATCH 4/5] clarify war settings --- src/warchron/view/ui/ui_settings_dialog.py | 165 +++++++++----- src/warchron/view/ui/ui_settings_dialog.ui | 240 +++++++++++++++++---- 2 files changed, 310 insertions(+), 95 deletions(-) diff --git a/src/warchron/view/ui/ui_settings_dialog.py b/src/warchron/view/ui/ui_settings_dialog.py index bf0f161..d051846 100644 --- a/src/warchron/view/ui/ui_settings_dialog.py +++ b/src/warchron/view/ui/ui_settings_dialog.py @@ -13,7 +13,7 @@ class Ui_settingsDialog(object): def setupUi(self, settingsDialog): settingsDialog.setObjectName("settingsDialog") settingsDialog.setWindowModality(QtCore.Qt.WindowModality.ApplicationModal) - settingsDialog.resize(661, 377) + settingsDialog.resize(621, 387) icon = QtGui.QIcon() icon.addPixmap(QtGui.QPixmap(".\\src\\warchron\\view\\ui\\../resources/warchron_logo.png"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off) settingsDialog.setWindowIcon(icon) @@ -24,8 +24,8 @@ class Ui_settingsDialog(object): font.setPointSize(10) self.groupBox_2.setFont(font) self.groupBox_2.setObjectName("groupBox_2") - self.verticalLayout_2 = QtWidgets.QVBoxLayout(self.groupBox_2) - self.verticalLayout_2.setObjectName("verticalLayout_2") + self.verticalLayout_3 = QtWidgets.QVBoxLayout(self.groupBox_2) + self.verticalLayout_3.setObjectName("verticalLayout_3") self.horizontalLayout = QtWidgets.QHBoxLayout() self.horizontalLayout.setObjectName("horizontalLayout") self.label_5 = QtWidgets.QLabel(parent=self.groupBox_2) @@ -56,25 +56,58 @@ class Ui_settingsDialog(object): self.horizontalLayout.addWidget(self.label_15) spacerItem1 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) self.horizontalLayout.addItem(spacerItem1) - self.verticalLayout_2.addLayout(self.horizontalLayout) - self.horizontalLayout_5 = QtWidgets.QHBoxLayout() - self.horizontalLayout_5.setObjectName("horizontalLayout_5") + self.verticalLayout_3.addLayout(self.horizontalLayout) + self.horizontalLayout_2 = QtWidgets.QHBoxLayout() + self.horizontalLayout_2.setObjectName("horizontalLayout_2") self.label_12 = QtWidgets.QLabel(parent=self.groupBox_2) self.label_12.setObjectName("label_12") - self.horizontalLayout_5.addWidget(self.label_12) - self.rankingComboBox = QtWidgets.QComboBox(parent=self.groupBox_2) - self.rankingComboBox.setEnabled(False) + self.horizontalLayout_2.addWidget(self.label_12) + self.pointsComboBox = QtWidgets.QComboBox(parent=self.groupBox_2) + self.pointsComboBox.setEnabled(False) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Fixed) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.rankingComboBox.sizePolicy().hasHeightForWidth()) - self.rankingComboBox.setSizePolicy(sizePolicy) - self.rankingComboBox.setObjectName("rankingComboBox") - self.rankingComboBox.addItem("") - self.horizontalLayout_5.addWidget(self.rankingComboBox) + sizePolicy.setHeightForWidth(self.pointsComboBox.sizePolicy().hasHeightForWidth()) + self.pointsComboBox.setSizePolicy(sizePolicy) + self.pointsComboBox.setObjectName("pointsComboBox") + self.pointsComboBox.addItem("") + self.pointsComboBox.addItem("") + self.horizontalLayout_2.addWidget(self.pointsComboBox) spacerItem2 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) - self.horizontalLayout_5.addItem(spacerItem2) - self.verticalLayout_2.addLayout(self.horizontalLayout_5) + self.horizontalLayout_2.addItem(spacerItem2) + self.internalTiebreak = QtWidgets.QCheckBox(parent=self.groupBox_2) + self.internalTiebreak.setEnabled(False) + self.internalTiebreak.setText("") + self.internalTiebreak.setCheckable(True) + self.internalTiebreak.setChecked(True) + self.internalTiebreak.setObjectName("internalTiebreak") + self.horizontalLayout_2.addWidget(self.internalTiebreak) + self.label_20 = QtWidgets.QLabel(parent=self.groupBox_2) + self.label_20.setObjectName("label_20") + self.horizontalLayout_2.addWidget(self.label_20) + spacerItem3 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) + self.horizontalLayout_2.addItem(spacerItem3) + self.verticalLayout_3.addLayout(self.horizontalLayout_2) + self.horizontalLayout_5 = QtWidgets.QHBoxLayout() + self.horizontalLayout_5.setObjectName("horizontalLayout_5") + self.label_19 = QtWidgets.QLabel(parent=self.groupBox_2) + self.label_19.setObjectName("label_19") + self.horizontalLayout_5.addWidget(self.label_19) + self.rankingComboBox_2 = QtWidgets.QComboBox(parent=self.groupBox_2) + self.rankingComboBox_2.setEnabled(False) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.rankingComboBox_2.sizePolicy().hasHeightForWidth()) + self.rankingComboBox_2.setSizePolicy(sizePolicy) + self.rankingComboBox_2.setObjectName("rankingComboBox_2") + self.rankingComboBox_2.addItem("") + self.rankingComboBox_2.addItem("") + self.rankingComboBox_2.addItem("") + self.horizontalLayout_5.addWidget(self.rankingComboBox_2) + spacerItem4 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) + self.horizontalLayout_5.addItem(spacerItem4) + self.verticalLayout_3.addLayout(self.horizontalLayout_5) self.verticalLayout_4.addWidget(self.groupBox_2) self.groupBox = QtWidgets.QGroupBox(parent=settingsDialog) font = QtGui.QFont() @@ -95,8 +128,8 @@ class Ui_settingsDialog(object): self.label_8 = QtWidgets.QLabel(parent=self.groupBox) self.label_8.setObjectName("label_8") self.horizontalLayout_3.addWidget(self.label_8) - spacerItem3 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) - self.horizontalLayout_3.addItem(spacerItem3) + spacerItem5 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) + self.horizontalLayout_3.addItem(spacerItem5) self.label_9 = QtWidgets.QLabel(parent=self.groupBox) self.label_9.setObjectName("label_9") self.horizontalLayout_3.addWidget(self.label_9) @@ -107,8 +140,8 @@ class Ui_settingsDialog(object): self.label_10 = QtWidgets.QLabel(parent=self.groupBox) self.label_10.setObjectName("label_10") self.horizontalLayout_3.addWidget(self.label_10) - spacerItem4 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) - self.horizontalLayout_3.addItem(spacerItem4) + spacerItem6 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) + self.horizontalLayout_3.addItem(spacerItem6) self.verticalLayout.addLayout(self.horizontalLayout_3) self.horizontalLayout_4 = QtWidgets.QHBoxLayout() self.horizontalLayout_4.setObjectName("horizontalLayout_4") @@ -121,8 +154,8 @@ class Ui_settingsDialog(object): self.influenceToken.setChecked(True) self.influenceToken.setObjectName("influenceToken") self.horizontalLayout_4.addWidget(self.influenceToken) - spacerItem5 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) - self.horizontalLayout_4.addItem(spacerItem5) + spacerItem7 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) + self.horizontalLayout_4.addItem(spacerItem7) self.verticalLayout.addLayout(self.horizontalLayout_4) self.verticalLayout_4.addWidget(self.groupBox) self.groupBox_3 = QtWidgets.QGroupBox(parent=settingsDialog) @@ -130,13 +163,45 @@ class Ui_settingsDialog(object): font.setPointSize(10) self.groupBox_3.setFont(font) self.groupBox_3.setObjectName("groupBox_3") - self.verticalLayout_3 = QtWidgets.QVBoxLayout(self.groupBox_3) - self.verticalLayout_3.setObjectName("verticalLayout_3") + self.verticalLayout_2 = QtWidgets.QVBoxLayout(self.groupBox_3) + self.verticalLayout_2.setObjectName("verticalLayout_2") self.horizontalLayout_7 = QtWidgets.QHBoxLayout() self.horizontalLayout_7.setObjectName("horizontalLayout_7") + self.label_21 = QtWidgets.QLabel(parent=self.groupBox_3) + self.label_21.setObjectName("label_21") + self.horizontalLayout_7.addWidget(self.label_21) + self.drawComboBox = QtWidgets.QComboBox(parent=self.groupBox_3) + self.drawComboBox.setEnabled(False) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.drawComboBox.sizePolicy().hasHeightForWidth()) + self.drawComboBox.setSizePolicy(sizePolicy) + self.drawComboBox.setObjectName("drawComboBox") + self.drawComboBox.addItem("") + self.drawComboBox.addItem("") + self.drawComboBox.addItem("") + self.horizontalLayout_7.addWidget(self.drawComboBox) + spacerItem8 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) + self.horizontalLayout_7.addItem(spacerItem8) + self.label_18 = QtWidgets.QLabel(parent=self.groupBox_3) + self.label_18.setObjectName("label_18") + self.horizontalLayout_7.addWidget(self.label_18) + self.shuffle = QtWidgets.QCheckBox(parent=self.groupBox_3) + self.shuffle.setEnabled(False) + self.shuffle.setText("") + self.shuffle.setCheckable(True) + self.shuffle.setChecked(True) + self.shuffle.setObjectName("shuffle") + self.horizontalLayout_7.addWidget(self.shuffle) + spacerItem9 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) + self.horizontalLayout_7.addItem(spacerItem9) + self.verticalLayout_2.addLayout(self.horizontalLayout_7) + self.horizontalLayout_8 = QtWidgets.QHBoxLayout() + self.horizontalLayout_8.setObjectName("horizontalLayout_8") self.label_17 = QtWidgets.QLabel(parent=self.groupBox_3) self.label_17.setObjectName("label_17") - self.horizontalLayout_7.addWidget(self.label_17) + self.horizontalLayout_8.addWidget(self.label_17) self.fallbackComboBox = QtWidgets.QComboBox(parent=self.groupBox_3) self.fallbackComboBox.setEnabled(False) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Fixed) @@ -146,22 +211,11 @@ class Ui_settingsDialog(object): self.fallbackComboBox.setSizePolicy(sizePolicy) self.fallbackComboBox.setObjectName("fallbackComboBox") self.fallbackComboBox.addItem("") - self.horizontalLayout_7.addWidget(self.fallbackComboBox) - spacerItem6 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) - self.horizontalLayout_7.addItem(spacerItem6) - self.label_18 = QtWidgets.QLabel(parent=self.groupBox_3) - self.label_18.setObjectName("label_18") - self.horizontalLayout_7.addWidget(self.label_18) - self.influenceToken_2 = QtWidgets.QCheckBox(parent=self.groupBox_3) - self.influenceToken_2.setEnabled(False) - self.influenceToken_2.setText("") - self.influenceToken_2.setCheckable(True) - self.influenceToken_2.setChecked(True) - self.influenceToken_2.setObjectName("influenceToken_2") - self.horizontalLayout_7.addWidget(self.influenceToken_2) - spacerItem7 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) - self.horizontalLayout_7.addItem(spacerItem7) - self.verticalLayout_3.addLayout(self.horizontalLayout_7) + self.fallbackComboBox.addItem("") + self.horizontalLayout_8.addWidget(self.fallbackComboBox) + spacerItem10 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) + self.horizontalLayout_8.addItem(spacerItem10) + self.verticalLayout_2.addLayout(self.horizontalLayout_8) self.horizontalLayout_6 = QtWidgets.QHBoxLayout() self.horizontalLayout_6.setObjectName("horizontalLayout_6") self.label_13 = QtWidgets.QLabel(parent=self.groupBox_3) @@ -172,8 +226,8 @@ class Ui_settingsDialog(object): self.rematchValue.setMinimum(1) self.rematchValue.setObjectName("rematchValue") self.horizontalLayout_6.addWidget(self.rematchValue) - spacerItem8 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) - self.horizontalLayout_6.addItem(spacerItem8) + spacerItem11 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) + self.horizontalLayout_6.addItem(spacerItem11) self.label_16 = QtWidgets.QLabel(parent=self.groupBox_3) self.label_16.setObjectName("label_16") self.horizontalLayout_6.addWidget(self.label_16) @@ -182,9 +236,9 @@ class Ui_settingsDialog(object): self.occupancyValue.setMinimum(1) self.occupancyValue.setObjectName("occupancyValue") self.horizontalLayout_6.addWidget(self.occupancyValue) - spacerItem9 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) - self.horizontalLayout_6.addItem(spacerItem9) - self.verticalLayout_3.addLayout(self.horizontalLayout_6) + spacerItem12 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) + self.horizontalLayout_6.addItem(spacerItem12) + self.verticalLayout_2.addLayout(self.horizontalLayout_6) self.verticalLayout_4.addWidget(self.groupBox_3) self.buttonBox = QtWidgets.QDialogButtonBox(parent=settingsDialog) self.buttonBox.setOrientation(QtCore.Qt.Orientation.Horizontal) @@ -205,8 +259,14 @@ class Ui_settingsDialog(object): self.label_14.setText(_translate("settingsDialog", "victory points")) self.label_6.setText(_translate("settingsDialog", "Draw")) self.label_15.setText(_translate("settingsDialog", "victory points")) - self.label_12.setText(_translate("settingsDialog", "Ranking mode")) - self.rankingComboBox.setItemText(0, _translate("settingsDialog", "Sum points & tie-breaks")) + self.label_12.setText(_translate("settingsDialog", "War points mode")) + self.pointsComboBox.setItemText(0, _translate("settingsDialog", "Sum battle points")) + self.pointsComboBox.setItemText(1, _translate("settingsDialog", "Sum campaign ranking")) + self.label_20.setText(_translate("settingsDialog", "Count internal tie-breaks")) + self.label_19.setText(_translate("settingsDialog", "Ranking mode")) + self.rankingComboBox_2.setItemText(0, _translate("settingsDialog", "Dense (1-2-2-3)")) + self.rankingComboBox_2.setItemText(1, _translate("settingsDialog", "Shift-up (1-2-2-4)")) + self.rankingComboBox_2.setItemText(2, _translate("settingsDialog", "Shift-down (1-3-3-4)")) self.groupBox.setTitle(_translate("settingsDialog", "Objectives")) self.label_4.setText(_translate("settingsDialog", "Major objective")) self.label_8.setText(_translate("settingsDialog", "narrative points")) @@ -215,9 +275,14 @@ class Ui_settingsDialog(object): self.label_11.setText(_translate("settingsDialog", "Underlying influence")) self.influenceToken.setText(_translate("settingsDialog", "Token")) self.groupBox_3.setTitle(_translate("settingsDialog", "Pairing")) + self.label_21.setText(_translate("settingsDialog", "Draw priority")) + self.drawComboBox.setItemText(0, _translate("settingsDialog", "Best war ranking")) + self.drawComboBox.setItemText(1, _translate("settingsDialog", "Random")) + self.drawComboBox.setItemText(2, _translate("settingsDialog", "Avoid rematch")) + self.label_18.setText(_translate("settingsDialog", "Shuffle groups")) self.label_17.setText(_translate("settingsDialog", "Fallback mode")) - self.fallbackComboBox.setItemText(0, _translate("settingsDialog", "Best ranking first & avoid rematch")) - self.label_18.setText(_translate("settingsDialog", "Shuffle")) + self.fallbackComboBox.setItemText(0, _translate("settingsDialog", "Avoid rematch")) + self.fallbackComboBox.setItemText(1, _translate("settingsDialog", "Random")) self.label_13.setText(_translate("settingsDialog", "Rematch weight")) self.label_16.setText(_translate("settingsDialog", "Occupancy weight")) diff --git a/src/warchron/view/ui/ui_settings_dialog.ui b/src/warchron/view/ui/ui_settings_dialog.ui index 2b371bc..cc251f0 100644 --- a/src/warchron/view/ui/ui_settings_dialog.ui +++ b/src/warchron/view/ui/ui_settings_dialog.ui @@ -9,8 +9,8 @@ 0 0 - 661 - 377 + 621 + 387 @@ -31,7 +31,7 @@ Scores - + @@ -117,16 +117,16 @@ - + - Ranking mode + War points mode - + false @@ -138,7 +138,12 @@ - Sum points & tie-breaks + Sum battle points + + + + + Sum campaign ranking @@ -156,6 +161,94 @@ + + + + false + + + + + + true + + + true + + + + + + + Count internal tie-breaks + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + Ranking mode + + + + + + + false + + + + 0 + 0 + + + + + Dense (1-2-2-3) + + + + + Shift-up (1-2-2-4) + + + + + Shift-down (1-3-3-4) + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + @@ -297,9 +390,97 @@ Pairing - + + + + + Draw priority + + + + + + + false + + + + 0 + 0 + + + + + Best war ranking + + + + + Random + + + + + Avoid rematch + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Shuffle groups + + + + + + + false + + + + + + true + + + true + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + @@ -320,7 +501,12 @@ - Best ranking first & avoid rematch + Avoid rematch + + + + + Random @@ -338,42 +524,6 @@ - - - - Shuffle - - - - - - - false - - - - - - true - - - true - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - From 2901dcd68c40eed828d8112e795f0587ebf66840 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20R=C3=A9aux?= Date: Sat, 28 Mar 2026 09:18:47 +0100 Subject: [PATCH 5/5] show player stats in table --- src/warchron/controller/app_controller.py | 13 ++- .../controller/campaign_controller.py | 2 +- src/warchron/controller/dtos.py | 6 ++ .../controller/navigation_controller.py | 34 ++++-- src/warchron/controller/round_controller.py | 4 +- src/warchron/controller/war_controller.py | 4 +- src/warchron/model/battle.py | 8 ++ src/warchron/model/model.py | 4 + src/warchron/model/statistics.py | 101 ++++++++++++++++++ src/warchron/view/view.py | 34 +++++- 10 files changed, 193 insertions(+), 17 deletions(-) create mode 100644 src/warchron/model/statistics.py diff --git a/src/warchron/controller/app_controller.py b/src/warchron/controller/app_controller.py index 7522e08..693cc60 100644 --- a/src/warchron/controller/app_controller.py +++ b/src/warchron/controller/app_controller.py @@ -26,6 +26,7 @@ class AppController: self.current_file: Path | None = None self.view.on_close_callback = self.on_app_close self.is_dirty: bool = False + self.players_stats_dirty = True self.__connect() self.navigation.refresh_players_view() self.navigation.refresh_wars_view() @@ -60,6 +61,12 @@ class AppController: self.view.on_add_item = self.add_item self.view.on_edit_item = self.edit_item self.view.on_delete_item = self.delete_item + self.view.tabWidget.currentChanged.connect(self.navigation.on_tab_changed) + + def mark_model_dirty(self, *, players_stats: bool = False) -> None: + self.is_dirty = True + if players_stats: + self.players_stats_dirty = True def on_app_close(self) -> bool: if self.is_dirty: @@ -239,7 +246,7 @@ class AppController: e.action() else: return - self.is_dirty = True + self.mark_model_dirty(players_stats=True) # participation may affect stats self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) def edit_item(self, item_type: str, item_id: str) -> None: @@ -302,7 +309,7 @@ class AppController: return else: return - self.is_dirty = True + self.mark_model_dirty(players_stats=(item_type == ItemType.PLAYER)) self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) def delete_item(self, item_type: str, item_id: str) -> None: @@ -374,5 +381,5 @@ class AppController: return else: return - self.is_dirty = True + self.mark_model_dirty(players_stats=True) # participation may affect stats self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) diff --git a/src/warchron/controller/campaign_controller.py b/src/warchron/controller/campaign_controller.py index 1775509..5ebc964 100644 --- a/src/warchron/controller/campaign_controller.py +++ b/src/warchron/controller/campaign_controller.py @@ -160,7 +160,7 @@ class CampaignController: str(e), ) return - self.app.is_dirty = True + self.app.mark_model_dirty(players_stats=True) self.app.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) self.app.navigation.refresh_and_select( RefreshScope.WARS_TREE, item_type=ItemType.CAMPAIGN, item_id=campaign_id diff --git a/src/warchron/controller/dtos.py b/src/warchron/controller/dtos.py index ddf777e..d646954 100644 --- a/src/warchron/controller/dtos.py +++ b/src/warchron/controller/dtos.py @@ -8,6 +8,12 @@ from PyQt6.QtGui import QIcon class ParticipantOption: id: str name: str + wars_played: int | None = None + wars_won: int | None = None + campaigns_played: int | None = None + campaigns_won: int | None = None + battles_played: int | None = None + battles_won: int | None = None @dataclass(frozen=True, slots=True) diff --git a/src/warchron/controller/navigation_controller.py b/src/warchron/controller/navigation_controller.py index 2a6aa47..a6035bd 100644 --- a/src/warchron/controller/navigation_controller.py +++ b/src/warchron/controller/navigation_controller.py @@ -6,11 +6,11 @@ if TYPE_CHECKING: from warchron.controller.app_controller import AppController from warchron.controller.dtos import ( TreeSelection, - ParticipantOption, WarDTO, CampaignDTO, RoundDTO, ) +from warchron.controller.dtos import ParticipantOption class NavigationController: @@ -23,13 +23,6 @@ class NavigationController: # Display methods - def refresh_players_view(self) -> None: - players = self.app.model.get_all_players() - players_for_display: List[ParticipantOption] = [ - ParticipantOption(id=p.id, name=p.name) for p in players - ] - self.app.view.display_players(players_for_display) - def refresh_wars_view(self) -> None: wars = self.app.model.get_all_wars() wars_dto: List[WarDTO] = [ @@ -74,6 +67,31 @@ class NavigationController: self.app.wars._fill_war_details(first_war.id) self.update_actions_state() + def on_tab_changed(self, index: int) -> None: + tab = self.app.view.get_current_tab() + if tab == "players" and self.app.players_stats_dirty: + self.refresh_players_view() + + def refresh_players_view(self) -> None: + players = self.app.model.get_all_players() + players_for_display: List[ParticipantOption] = [] + for p in players: + stats = self.app.model.get_player_stats(p.id) + players_for_display.append( + ParticipantOption( + id=p.id, + name=p.name, + wars_played=stats.wars_played, + wars_won=stats.wars_won, + campaigns_played=stats.campaigns_played, + campaigns_won=stats.campaigns_won, + battles_played=stats.battles_played, + battles_won=stats.battles_won, + ) + ) + self.app.players_stats_dirty = False + self.app.view.display_players(players_for_display) + def refresh(self, scope: RefreshScope) -> None: match scope: case RefreshScope.PLAYERS_LIST: diff --git a/src/warchron/controller/round_controller.py b/src/warchron/controller/round_controller.py index e0612fe..25585a0 100644 --- a/src/warchron/controller/round_controller.py +++ b/src/warchron/controller/round_controller.py @@ -217,7 +217,7 @@ class RoundController: break if stop: return - self.app.is_dirty = True + self.app.mark_model_dirty(players_stats=True) self.app.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) self.app.navigation.refresh_and_select( RefreshScope.WARS_TREE, item_type=ItemType.ROUND, item_id=round_id @@ -260,7 +260,7 @@ class RoundController: e.action() else: return - self.app.is_dirty = True + self.app.mark_model_dirty() self.app.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) self.app.navigation.refresh_and_select( RefreshScope.WARS_TREE, item_type=ItemType.ROUND, item_id=round_id diff --git a/src/warchron/controller/war_controller.py b/src/warchron/controller/war_controller.py index eb6848e..8546e58 100644 --- a/src/warchron/controller/war_controller.py +++ b/src/warchron/controller/war_controller.py @@ -147,7 +147,7 @@ class WarController: str(e), ) return - self.app.is_dirty = True + self.app.mark_model_dirty(players_stats=True) self.app.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) self.app.navigation.refresh_and_select( RefreshScope.WARS_TREE, item_type=ItemType.WAR, item_id=war_id @@ -225,7 +225,7 @@ class WarController: e.action() else: return - self.is_dirty = True + self.app.mark_model_dirty() self.app.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) self.app.navigation.refresh_and_select( RefreshScope.WARS_TREE, item_type=ItemType.WAR, item_id=war_id diff --git a/src/warchron/model/battle.py b/src/warchron/model/battle.py index 3a105ce..d54caaf 100644 --- a/src/warchron/model/battle.py +++ b/src/warchron/model/battle.py @@ -73,6 +73,14 @@ class Battle: return raise DomainError("Battle has no available places") + def get_participants_ids(self) -> List[str]: + players: List[str] = [] + if self.player_1_id: + players.append(self.player_1_id) + if self.player_2_id: + players.append(self.player_2_id) + return players + def clear_battle_players(self) -> None: self.player_1_id = None self.player_2_id = None diff --git a/src/warchron/model/model.py b/src/warchron/model/model.py index d1bab74..a122fe0 100644 --- a/src/warchron/model/model.py +++ b/src/warchron/model/model.py @@ -15,6 +15,7 @@ from warchron.model.sector import Sector from warchron.model.round import Round from warchron.model.choice import Choice from warchron.model.battle import Battle +from warchron.model.statistics import PlayerStats, StatisticsComputer class Model: @@ -98,6 +99,9 @@ class Model: ) del self.players[player_id] + def get_player_stats(self, player_id: str) -> PlayerStats: + return StatisticsComputer.compute_player_stats(self.wars, player_id) + # War methods def get_default_war_values(self) -> Dict[str, Any]: diff --git a/src/warchron/model/statistics.py b/src/warchron/model/statistics.py new file mode 100644 index 0000000..c1bcb11 --- /dev/null +++ b/src/warchron/model/statistics.py @@ -0,0 +1,101 @@ +from __future__ import annotations +from typing import Dict +from dataclasses import dataclass + +from warchron.constants import ContextType, ScoreKind +from warchron.model.war import War +from warchron.model.scoring import ScoreComputer +from warchron.model.checking import ResultChecker + + +@dataclass +class PlayerStats: + wars_played: int = 0 + wars_won: int = 0 + campaigns_played: int = 0 + campaigns_won: int = 0 + battles_played: int = 0 + battles_won: int = 0 + + +class StatisticsComputer: + + @staticmethod + def compute_player_stats( + wars: Dict[str, War], + player_id: str, + ) -> PlayerStats: + stats = PlayerStats() + for war in wars.values(): + # --- WAR PARTICIPANT --- + war_part = next( + (wp for wp in war.participants.values() if wp.player_id == player_id), + None, + ) + if not war_part: + continue + stats.wars_played += 1 + if war.is_over: + scores = ScoreComputer.compute_scores(war, ContextType.WAR, war.id) + ranking = ResultChecker.get_effective_ranking( + war, + ContextType.WAR, + war.id, + ScoreKind.VP, + scores, + lambda s: s.victory_points, + ) + if ranking and war_part.id in ranking[0][1]: + stats.wars_won += 1 + # --- CAMPAIGNS --- + for campaign in war.campaigns: + campaign_part = next( + ( + cp + for cp in campaign.participants.values() + if cp.war_participant_id == war_part.id + ), + None, + ) + if not campaign_part: + continue + stats.campaigns_played += 1 + if campaign.is_over: + scores = ScoreComputer.compute_scores( + war, ContextType.CAMPAIGN, campaign.id + ) + ranking = ResultChecker.get_effective_ranking( + war, + ContextType.CAMPAIGN, + campaign.id, + ScoreKind.VP, + scores, + lambda s: s.victory_points, + ) + if ranking and war_part.id in ranking[0][1]: + stats.campaigns_won += 1 + # --- BATTLES --- + for rnd in campaign.rounds: + for battle in rnd.battles.values(): + if not battle.is_finished(): + continue + if campaign_part.id not in ( + battle.player_1_id, + battle.player_2_id, + ): + continue + stats.battles_played += 1 + base_winner = None + if battle.winner_id is not None: + base_winner = campaign.campaign_to_war_part_id( + battle.winner_id + ) + winner = ResultChecker.get_effective_winner_id( + war, + ContextType.BATTLE, + battle.sector_id, + base_winner, + ) + if winner == war_part.id: + stats.battles_won += 1 + return stats diff --git a/src/warchron/view/view.py b/src/warchron/view/view.py index c34077d..d6a8709 100644 --- a/src/warchron/view/view.py +++ b/src/warchron/view/view.py @@ -200,15 +200,47 @@ class View(QtWidgets.QMainWindow, Ui_MainWindow): elif action == delete_action and self.on_delete_item: self.on_delete_item(ItemType.PLAYER, player_id) + def _make_ratio_item( + self, won: int | None, played: int | None + ) -> QtWidgets.QTableWidgetItem: + if not played: + text = "—" + ratio = -1.0 + else: + won = won or 0 + text = f"{won} / {played}" + ratio = won / played + item = QtWidgets.QTableWidgetItem(text) + item.setData(Qt.ItemDataRole.UserRole, ratio) + return item + def display_players(self, players: List[ParticipantOption]) -> None: - # TODO display stats (war, campaign battles...) table = self.playersTable table.setSortingEnabled(False) + table.setColumnCount(4) + table.setHorizontalHeaderLabels(["Name", "Wars", "Campaigns", "Battles"]) table.setRowCount(len(players)) for row, player in enumerate(players): play_item = QtWidgets.QTableWidgetItem(player.name) + wars_item = self._make_ratio_item( + player.wars_won, + player.wars_played, + ) + + camp_item = self._make_ratio_item( + player.campaigns_won, + player.campaigns_played, + ) + + bat_item = self._make_ratio_item( + player.battles_won, + player.battles_played, + ) play_item.setData(Qt.ItemDataRole.UserRole, player.id) table.setItem(row, 0, play_item) + table.setItem(row, 1, wars_item) + table.setItem(row, 2, camp_item) + table.setItem(row, 3, bat_item) table.setSortingEnabled(True) table.sortItems(0, Qt.SortOrder.AscendingOrder) table.resizeColumnsToContents()