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] 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()