show player stats in table

This commit is contained in:
Maxime Réaux 2026-03-28 09:18:47 +01:00
parent 261c64942d
commit 2901dcd68c
10 changed files with 193 additions and 17 deletions

View file

@ -26,6 +26,7 @@ class AppController:
self.current_file: Path | None = None self.current_file: Path | None = None
self.view.on_close_callback = self.on_app_close self.view.on_close_callback = self.on_app_close
self.is_dirty: bool = False self.is_dirty: bool = False
self.players_stats_dirty = True
self.__connect() self.__connect()
self.navigation.refresh_players_view() self.navigation.refresh_players_view()
self.navigation.refresh_wars_view() self.navigation.refresh_wars_view()
@ -60,6 +61,12 @@ class AppController:
self.view.on_add_item = self.add_item self.view.on_add_item = self.add_item
self.view.on_edit_item = self.edit_item self.view.on_edit_item = self.edit_item
self.view.on_delete_item = self.delete_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: def on_app_close(self) -> bool:
if self.is_dirty: if self.is_dirty:
@ -239,7 +246,7 @@ class AppController:
e.action() e.action()
else: else:
return return
self.is_dirty = True self.mark_model_dirty(players_stats=True) # participation may affect stats
self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS)
def edit_item(self, item_type: str, item_id: str) -> None: def edit_item(self, item_type: str, item_id: str) -> None:
@ -302,7 +309,7 @@ class AppController:
return return
else: else:
return return
self.is_dirty = True self.mark_model_dirty(players_stats=(item_type == ItemType.PLAYER))
self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS)
def delete_item(self, item_type: str, item_id: str) -> None: def delete_item(self, item_type: str, item_id: str) -> None:
@ -374,5 +381,5 @@ class AppController:
return return
else: else:
return return
self.is_dirty = True self.mark_model_dirty(players_stats=True) # participation may affect stats
self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) self.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS)

View file

@ -160,7 +160,7 @@ class CampaignController:
str(e), str(e),
) )
return 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(RefreshScope.CURRENT_SELECTION_DETAILS)
self.app.navigation.refresh_and_select( self.app.navigation.refresh_and_select(
RefreshScope.WARS_TREE, item_type=ItemType.CAMPAIGN, item_id=campaign_id RefreshScope.WARS_TREE, item_type=ItemType.CAMPAIGN, item_id=campaign_id

View file

@ -8,6 +8,12 @@ from PyQt6.QtGui import QIcon
class ParticipantOption: class ParticipantOption:
id: str id: str
name: 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) @dataclass(frozen=True, slots=True)

View file

@ -6,11 +6,11 @@ if TYPE_CHECKING:
from warchron.controller.app_controller import AppController from warchron.controller.app_controller import AppController
from warchron.controller.dtos import ( from warchron.controller.dtos import (
TreeSelection, TreeSelection,
ParticipantOption,
WarDTO, WarDTO,
CampaignDTO, CampaignDTO,
RoundDTO, RoundDTO,
) )
from warchron.controller.dtos import ParticipantOption
class NavigationController: class NavigationController:
@ -23,13 +23,6 @@ class NavigationController:
# Display methods # 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: def refresh_wars_view(self) -> None:
wars = self.app.model.get_all_wars() wars = self.app.model.get_all_wars()
wars_dto: List[WarDTO] = [ wars_dto: List[WarDTO] = [
@ -74,6 +67,31 @@ class NavigationController:
self.app.wars._fill_war_details(first_war.id) self.app.wars._fill_war_details(first_war.id)
self.update_actions_state() 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: def refresh(self, scope: RefreshScope) -> None:
match scope: match scope:
case RefreshScope.PLAYERS_LIST: case RefreshScope.PLAYERS_LIST:

View file

@ -217,7 +217,7 @@ class RoundController:
break break
if stop: if stop:
return 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(RefreshScope.CURRENT_SELECTION_DETAILS)
self.app.navigation.refresh_and_select( self.app.navigation.refresh_and_select(
RefreshScope.WARS_TREE, item_type=ItemType.ROUND, item_id=round_id RefreshScope.WARS_TREE, item_type=ItemType.ROUND, item_id=round_id
@ -260,7 +260,7 @@ class RoundController:
e.action() e.action()
else: else:
return return
self.app.is_dirty = True self.app.mark_model_dirty()
self.app.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) self.app.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS)
self.app.navigation.refresh_and_select( self.app.navigation.refresh_and_select(
RefreshScope.WARS_TREE, item_type=ItemType.ROUND, item_id=round_id RefreshScope.WARS_TREE, item_type=ItemType.ROUND, item_id=round_id

View file

@ -147,7 +147,7 @@ class WarController:
str(e), str(e),
) )
return 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(RefreshScope.CURRENT_SELECTION_DETAILS)
self.app.navigation.refresh_and_select( self.app.navigation.refresh_and_select(
RefreshScope.WARS_TREE, item_type=ItemType.WAR, item_id=war_id RefreshScope.WARS_TREE, item_type=ItemType.WAR, item_id=war_id
@ -225,7 +225,7 @@ class WarController:
e.action() e.action()
else: else:
return return
self.is_dirty = True self.app.mark_model_dirty()
self.app.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) self.app.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS)
self.app.navigation.refresh_and_select( self.app.navigation.refresh_and_select(
RefreshScope.WARS_TREE, item_type=ItemType.WAR, item_id=war_id RefreshScope.WARS_TREE, item_type=ItemType.WAR, item_id=war_id

View file

@ -73,6 +73,14 @@ class Battle:
return return
raise DomainError("Battle has no available places") 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: def clear_battle_players(self) -> None:
self.player_1_id = None self.player_1_id = None
self.player_2_id = None self.player_2_id = None

View file

@ -15,6 +15,7 @@ from warchron.model.sector import Sector
from warchron.model.round import Round from warchron.model.round import Round
from warchron.model.choice import Choice from warchron.model.choice import Choice
from warchron.model.battle import Battle from warchron.model.battle import Battle
from warchron.model.statistics import PlayerStats, StatisticsComputer
class Model: class Model:
@ -98,6 +99,9 @@ class Model:
) )
del self.players[player_id] 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 # War methods
def get_default_war_values(self) -> Dict[str, Any]: def get_default_war_values(self) -> Dict[str, Any]:

View file

@ -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

View file

@ -200,15 +200,47 @@ class View(QtWidgets.QMainWindow, Ui_MainWindow):
elif action == delete_action and self.on_delete_item: elif action == delete_action and self.on_delete_item:
self.on_delete_item(ItemType.PLAYER, player_id) 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: def display_players(self, players: List[ParticipantOption]) -> None:
# TODO display stats (war, campaign battles...)
table = self.playersTable table = self.playersTable
table.setSortingEnabled(False) table.setSortingEnabled(False)
table.setColumnCount(4)
table.setHorizontalHeaderLabels(["Name", "Wars", "Campaigns", "Battles"])
table.setRowCount(len(players)) table.setRowCount(len(players))
for row, player in enumerate(players): for row, player in enumerate(players):
play_item = QtWidgets.QTableWidgetItem(player.name) 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) play_item.setData(Qt.ItemDataRole.UserRole, player.id)
table.setItem(row, 0, play_item) 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.setSortingEnabled(True)
table.sortItems(0, Qt.SortOrder.AscendingOrder) table.sortItems(0, Qt.SortOrder.AscendingOrder)
table.resizeColumnsToContents() table.resizeColumnsToContents()