From 7c9c9418640040fd4988aa42408c393b62c02e75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20R=C3=A9aux?= Date: Thu, 19 Feb 2026 14:17:42 +0100 Subject: [PATCH] compute campaign points from rounds --- README.md | 16 +-- .../controller/campaign_controller.py | 36 ++++-- src/warchron/controller/dtos.py | 19 ++++ src/warchron/model/battle.py | 7 +- src/warchron/model/campaign.py | 24 ++-- src/warchron/model/closure_service.py | 3 +- src/warchron/model/score_service.py | 106 ++++++++++++------ src/warchron/model/war.py | 9 ++ src/warchron/view/view.py | 20 +++- 9 files changed, 165 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index 79a292d..6b8c7e7 100644 --- a/README.md +++ b/README.md @@ -7,16 +7,12 @@ A simple local app to track players' campaigns for tabletop wargames. ### Main logic Manage a list of players to sign them up to be selectable for war(s) and campaign(s). -A "war" year contains several "campaign" events which contain several "battle" games organised in successive rounds. -Battle results determine campaign score which determines the war score. -Wars are independent. - -### Design notes - -* Players are global identities -* Influence tokens are scoped to a war -* Campaign order enables historical tie-breakers -* Effects are generic → future-proof +A war year offers various objectives along several campaigns. Wars are independent. +A campaign event presents customisable sectors to fight on during battle rounds. Campaigns are successive and are used for historical tie-breaker. +A round includes battles to combine all participants according to their choice. Rounds are successive and are used for participants pairing in different priority modes. +Winning battle grants victory points, narrative points (optional) and influence token (optional). +Round results determine campaign score, which determines the war score, in different counting modes. +Victory points determine the winner, narrative points grant scenario award(s) and influence tokens decide tie-breaks. ## Installation diff --git a/src/warchron/controller/campaign_controller.py b/src/warchron/controller/campaign_controller.py index 022fd54..3ae38cb 100644 --- a/src/warchron/controller/campaign_controller.py +++ b/src/warchron/controller/campaign_controller.py @@ -2,21 +2,22 @@ from typing import List, TYPE_CHECKING from PyQt6.QtWidgets import QMessageBox, QDialog -from warchron.constants import RefreshScope +from warchron.constants import RefreshScope, ContextType if TYPE_CHECKING: from warchron.controller.app_controller import AppController from warchron.controller.dtos import ( ParticipantOption, ObjectiveDTO, - CampaignParticipantDTO, SectorDTO, RoundDTO, + CampaignParticipantScoreDTO, ) from warchron.model.campaign import Campaign from warchron.model.campaign_participant import CampaignParticipant from warchron.model.sector import Sector from warchron.model.closure_service import ClosureService +from warchron.model.score_service import ScoreService from warchron.view.campaign_dialog import CampaignDialog from warchron.view.campaign_participant_dialog import CampaignParticipantDialog from warchron.view.sector_dialog import SectorDialog @@ -45,17 +46,28 @@ class CampaignController: for sect in sectors ] self.app.view.display_campaign_sectors(sectors_for_display) - camp_parts = camp.get_all_campaign_participants() - participants_for_display: List[CampaignParticipantDTO] = [ - CampaignParticipantDTO( - id=p.id, - player_name=self.app.model.get_participant_name(p.war_participant_id), - leader=p.leader or "", - theme=p.theme or "", + scores = ScoreService.compute_scores(war, ContextType.CAMPAIGN, campaign_id) + rows: List[CampaignParticipantScoreDTO] = [] + 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) + player_name = self.app.model.get_player_name(war_part.player_id) + score = scores[war_part_id] + rows.append( + CampaignParticipantScoreDTO( + campaign_participant_id=camp_part.id, + war_participant_id=war_part_id, + player_name=player_name, + leader=camp_part.leader or "", + theme=camp_part.theme or "", + victory_points=score.victory_points, + narrative_points=dict(score.narrative_points), + ) ) - for p in camp_parts + objectives = [ + ObjectiveDTO(o.id, o.name, o.description) for o in war.get_all_objectives() ] - self.app.view.display_campaign_participants(participants_for_display) + self.app.view.display_campaign_participants(rows, objectives) self.app.view.endCampaignBtn.setEnabled(not camp.is_over) def _validate_campaign_inputs(self, name: str, month: int) -> bool: @@ -189,7 +201,7 @@ class CampaignController: self.app.view, "Invalid name", "Sector name cannot be empty." ) return False - # allow same objectives in different fields? + # TODO allow same objectives in different fields? return True def create_sector(self) -> Sector | None: diff --git a/src/warchron/controller/dtos.py b/src/warchron/controller/dtos.py index 5222b03..bbfc45b 100644 --- a/src/warchron/controller/dtos.py +++ b/src/warchron/controller/dtos.py @@ -114,3 +114,22 @@ class TieContext: context_type: ContextType context_id: str participants: List[str] # war_participant_ids + + +@dataclass(frozen=True, slots=True) +class ParticipantScoreDTO: + participant_id: str + player_name: str + victory_points: int + narrative_points: dict[str, int] + + +@dataclass(frozen=True, slots=True) +class CampaignParticipantScoreDTO: + campaign_participant_id: str + war_participant_id: str + player_name: str + leader: str + theme: str + victory_points: int + narrative_points: dict[str, int] diff --git a/src/warchron/model/battle.py b/src/warchron/model/battle.py index 1af9b2b..3ff23a7 100644 --- a/src/warchron/model/battle.py +++ b/src/warchron/model/battle.py @@ -43,12 +43,13 @@ class Battle: def is_draw(self) -> bool: if self.winner_id is not None: return False - # Case 1: score entered → interpreted as unresolved outcome if self.score and self.score.strip(): return True - # Case 2: explicit draw mention if self.victory_condition: - if "draw" in self.victory_condition.casefold(): + if any( + keyword in (self.victory_condition or "").casefold() + for keyword in ["draw", "tie", "square"] + ): return True return False diff --git a/src/warchron/model/campaign.py b/src/warchron/model/campaign.py index 2b8148a..e2fce92 100644 --- a/src/warchron/model/campaign.py +++ b/src/warchron/model/campaign.py @@ -284,6 +284,22 @@ class Campaign: return rnd raise KeyError(f"Round {round_id} not found") + def get_round_index(self, round_id: str | None) -> int | None: + if round_id is None: + return None + for index, rnd in enumerate(self.rounds, start=1): + if rnd.id == round_id: + return index + raise KeyError("Round not found in campaign") + + # TODO replace multiloops by internal has_* method + def get_round_by_battle(self, sector_id: str) -> Round: + for rnd in self.rounds: + for bat in rnd.battles.values(): + if bat.sector_id == sector_id: + return rnd + raise KeyError(f"Battle {sector_id} not found in any Round") + def get_all_rounds(self) -> List[Round]: return list(self.rounds) @@ -309,14 +325,6 @@ class Campaign: if rnd: self.rounds.remove(rnd) - def get_round_index(self, round_id: str | None) -> int | None: - if round_id is None: - return None - for index, rnd in enumerate(self.rounds, start=1): - if rnd.id == round_id: - return index - raise KeyError("Round not found in campaign") - # Choice methods def create_choice(self, round_id: str, participant_id: str) -> Choice: diff --git a/src/warchron/model/closure_service.py b/src/warchron/model/closure_service.py index fb31372..5e6446e 100644 --- a/src/warchron/model/closure_service.py +++ b/src/warchron/model/closure_service.py @@ -32,10 +32,9 @@ class ClosureService: ) if already_granted: return + base_winner = None if battle.winner_id is not None: base_winner = campaign.participants[battle.winner_id].war_participant_id - else: - base_winner = None effective_winner = TieResolver.get_effective_winner_id( war, ContextType.BATTLE, diff --git a/src/warchron/model/score_service.py b/src/warchron/model/score_service.py index 298139f..9274f40 100644 --- a/src/warchron/model/score_service.py +++ b/src/warchron/model/score_service.py @@ -1,45 +1,77 @@ -from typing import Dict, TYPE_CHECKING +from typing import Dict, Iterator +from dataclasses import dataclass, field -if TYPE_CHECKING: - from warchron.model.war import War +from warchron.constants import ContextType +from warchron.model.tie_manager import TieResolver +from warchron.model.war import War +from warchron.model.battle import Battle + + +@dataclass(slots=True) +class ParticipantScore: + victory_points: int = 0 + narrative_points: Dict[str, int] = field(default_factory=dict) class ScoreService: @staticmethod - def compute_victory_points_for_participant(war: "War", participant_id: str) -> int: - total = 0 - for campaign in war.campaigns: - for round_ in campaign.rounds: - for battle in round_.battles.values(): - if battle.winner_id == participant_id: - sector = campaign.sectors[battle.sector_id] - if sector.major_objective_id: - total += war.major_value - if sector.minor_objective_id: - total += war.minor_value - return total + def _get_battles_for_context( + war: War, context_type: ContextType, context_id: str + ) -> Iterator[Battle]: + if context_type == ContextType.WAR: + for camp in war.campaigns: + for rnd in camp.rounds: + if not rnd.is_over: + continue + yield from rnd.battles.values() + + elif context_type == ContextType.CAMPAIGN: + campaign = war.get_campaign(context_id) + for rnd in campaign.rounds: + if not rnd.is_over: + continue + yield from rnd.battles.values() + + elif context_type == ContextType.BATTLE: + battle = war.get_battle(context_id) + campaign = war.get_campaign_by_sector(battle.sector_id) + rnd = campaign.get_round_by_battle(context_id) + if rnd and rnd.is_over: + yield battle @staticmethod - def compute_narrative_points_for_participant( - war: "War", participant_id: str - ) -> Dict[str, int]: - totals: Dict[str, int] = {} - for obj_id in war.objectives: - totals[obj_id] = 0 - for campaign in war.campaigns: - for round_ in campaign.rounds: - for battle in round_.battles.values(): - if battle.winner_id == participant_id: - sector = campaign.sectors[battle.sector_id] - if sector.major_objective_id: - totals[sector.major_objective_id] += war.major_value - if sector.minor_objective_id: - totals[sector.minor_objective_id] += war.minor_value - return totals - - # def compute_round_results(round) - - # def compute_campaign_winner(campaign) - - # def compute_war_winner(war) + def compute_scores( + war: War, context_type: ContextType, context_id: str + ) -> Dict[str, ParticipantScore]: + scores = { + pid: ParticipantScore( + narrative_points={obj_id: 0 for obj_id in war.objectives} + ) + for pid in war.participants + } + battles = ScoreService._get_battles_for_context(war, context_type, context_id) + for battle in battles: + base_winner = None + if battle.winner_id is not None: + campaign = war.get_campaign_by_campaign_participant(battle.winner_id) + if campaign is not None: + base_winner = campaign.participants[ + battle.winner_id + ].war_participant_id + winner = TieResolver.get_effective_winner_id( + war, ContextType.BATTLE, battle.sector_id, base_winner + ) + if winner is None: + continue + scores[winner].victory_points += 1 + sector = war.get_sector(battle.sector_id) + if sector.major_objective_id: + scores[winner].narrative_points[ + sector.major_objective_id + ] += war.major_value + if sector.minor_objective_id: + scores[winner].narrative_points[ + sector.minor_objective_id + ] += war.minor_value + return scores diff --git a/src/warchron/model/war.py b/src/warchron/model/war.py index 93e9cc5..9091e61 100644 --- a/src/warchron/model/war.py +++ b/src/warchron/model/war.py @@ -413,6 +413,15 @@ class War: # Battle methods + # TODO replace multiloops by internal has_* method + def get_battle(self, battle_id: str) -> Battle: + for camp in self.campaigns: + for rnd in camp.rounds: + for bat in rnd.battles.values(): + if bat.sector_id == battle_id: + return bat + raise KeyError("Round not found") + def create_battle(self, round_id: str, sector_id: str) -> Battle: camp = self.get_campaign_by_round(round_id) if camp is not None: diff --git a/src/warchron/view/view.py b/src/warchron/view/view.py index 5082df5..aae2527 100644 --- a/src/warchron/view/view.py +++ b/src/warchron/view/view.py @@ -15,10 +15,10 @@ from warchron.controller.dtos import ( WarDTO, WarParticipantDTO, ObjectiveDTO, - CampaignParticipantDTO, SectorDTO, ChoiceDTO, BattleDTO, + CampaignParticipantScoreDTO, ) from warchron.view.helpers import ( format_campaign_label, @@ -464,19 +464,33 @@ class View(QtWidgets.QMainWindow, Ui_MainWindow): table.resizeColumnsToContents() def display_campaign_participants( - self, participants: List[CampaignParticipantDTO] + self, + participants: List[CampaignParticipantScoreDTO], + objectives: List[ObjectiveDTO], ) -> None: table = self.campaignParticipantsTable table.clearContents() + base_cols = ["Player", "Leader", "Theme", "Victory"] + headers = base_cols + [obj.name for obj in objectives] + table.setColumnCount(len(headers)) + table.setHorizontalHeaderLabels(headers) table.setRowCount(len(participants)) for row, part in enumerate(participants): name_item = QtWidgets.QTableWidgetItem(part.player_name) lead_item = QtWidgets.QTableWidgetItem(part.leader) theme_item = QtWidgets.QTableWidgetItem(part.theme) - name_item.setData(Qt.ItemDataRole.UserRole, part.id) + VP_item = QtWidgets.QTableWidgetItem(str(part.victory_points)) + name_item.setData(Qt.ItemDataRole.UserRole, part.campaign_participant_id) table.setItem(row, 0, name_item) table.setItem(row, 1, lead_item) table.setItem(row, 2, theme_item) + table.setItem(row, 3, VP_item) + col = 4 + for obj in objectives: + value = part.narrative_points.get(obj.id, 0) + NP_item = QtWidgets.QTableWidgetItem(str(value)) + table.setItem(row, col, NP_item) + col += 1 table.resizeColumnsToContents() # Round page