From 6cbb7c6534502723563db8fe9e1e2ba1f9eadf72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20R=C3=A9aux?= Date: Wed, 11 Feb 2026 19:22:43 +0100 Subject: [PATCH] wip close round/campaign/war + refacto json None --- src/warchron/constants.py | 7 ++ src/warchron/controller/app_controller.py | 3 + .../controller/campaign_controller.py | 31 ++++++- src/warchron/controller/dtos.py | 16 ++-- src/warchron/controller/round_controller.py | 27 +++++- src/warchron/controller/war_controller.py | 26 +++++- src/warchron/model/battle.py | 28 ++++--- src/warchron/model/campaign.py | 18 ++-- src/warchron/model/choice.py | 12 +-- src/warchron/model/closure_service.py | 83 +++++++++++++++++++ src/warchron/model/influence_service.py | 23 +++++ src/warchron/model/model.py | 32 +++---- src/warchron/model/objective.py | 10 +-- src/warchron/model/round.py | 5 ++ src/warchron/model/score_service.py | 39 +++++++++ src/warchron/model/sector.py | 30 +++---- src/warchron/model/tie_manager.py | 46 ++++++++++ src/warchron/model/war.py | 15 ++-- src/warchron/model/war_event.py | 30 +++++++ src/warchron/model/war_participant.py | 20 +++++ src/warchron/view/battles_dialog.py | 30 ++++--- src/warchron/view/choices_dialog.py | 15 ++-- src/warchron/view/objective_dialog.py | 5 +- src/warchron/view/sector_dialog.py | 23 +++-- src/warchron/view/ui/ui_main_window.py | 4 +- src/warchron/view/ui/ui_main_window.ui | 4 +- 26 files changed, 474 insertions(+), 108 deletions(-) create mode 100644 src/warchron/model/closure_service.py create mode 100644 src/warchron/model/influence_service.py create mode 100644 src/warchron/model/score_service.py create mode 100644 src/warchron/model/tie_manager.py create mode 100644 src/warchron/model/war_event.py diff --git a/src/warchron/constants.py b/src/warchron/constants.py index 2566267..9abeec3 100644 --- a/src/warchron/constants.py +++ b/src/warchron/constants.py @@ -28,3 +28,10 @@ class RefreshScope(Enum): CAMPAIGN_DETAILS = auto() ROUND_DETAILS = auto() CURRENT_SELECTION_DETAILS = auto() + + +class ContextType(StrEnum): + WAR = "war" + CAMPAIGN = "campaign" + CHOICE = "choice" + BATTLE = "battle" diff --git a/src/warchron/controller/app_controller.py b/src/warchron/controller/app_controller.py index 9ca5341..e4356e8 100644 --- a/src/warchron/controller/app_controller.py +++ b/src/warchron/controller/app_controller.py @@ -52,10 +52,13 @@ class AppController: self.view.influenceToken.toggled.connect(self.wars.set_influence_token) self.view.addObjectiveBtn.clicked.connect(self.wars.add_objective) self.view.addWarParticipantBtn.clicked.connect(self.wars.add_war_participant) + self.view.endWarBtn.clicked.connect(self.wars.close_war) self.view.addSectorBtn.clicked.connect(self.campaigns.add_sector) self.view.addCampaignParticipantBtn.clicked.connect( self.campaigns.add_campaign_participant ) + self.view.endCampaignBtn.clicked.connect(self.campaigns.close_campaign) + self.view.endRoundBtn.clicked.connect(self.rounds.close_round) self.view.on_edit_item = self.edit_item self.view.on_delete_item = self.delete_item diff --git a/src/warchron/controller/campaign_controller.py b/src/warchron/controller/campaign_controller.py index 0521783..96c3a9a 100644 --- a/src/warchron/controller/campaign_controller.py +++ b/src/warchron/controller/campaign_controller.py @@ -13,6 +13,7 @@ from warchron.controller.dtos import ( SectorDTO, RoundDTO, ) +from warchron.model.closure_service import ClosureService from warchron.view.campaign_dialog import CampaignDialog from warchron.view.campaign_participant_dialog import CampaignParticipantDialog from warchron.view.sector_dialog import SectorDialog @@ -50,6 +51,7 @@ class CampaignController: for p in camp_parts ] self.app.view.display_campaign_participants(participants_for_display) + self.app.view.endCampaignBtn.setEnabled(not camp.is_over) def _validate_campaign_inputs(self, name: str, month: int) -> bool: if not name.strip(): @@ -99,6 +101,28 @@ class CampaignController: return self.app.model.update_campaign(campaign_id, name=name, month=month) + def close_campaign(self) -> None: + campaign_id = self.app.navigation.selected_campaign_id + if not campaign_id: + return + camp = self.app.model.get_campaign(campaign_id) + if camp.is_over: + return + try: + ties = ClosureService.close_campaign(camp) + except RuntimeError as e: + QMessageBox.warning(self.app.view, "Cannot close campaign", str(e)) + return + if ties: + QMessageBox.information( + self.app.view, + "Tie detected", + "Campaign has unresolved ties.", + ) + return + self.app.is_dirty = True + self.app.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) + # Campaign participant methods def add_campaign_participant(self) -> None: @@ -148,7 +172,12 @@ class CampaignController: # Sector methods def _validate_sector_inputs( - self, name: str, round_id: str, major_id: str, minor_id: str, influence_id: str + self, + name: str, + round_id: str | None, + major_id: str | None, + minor_id: str | None, + influence_id: str | None, ) -> bool: if not name.strip(): diff --git a/src/warchron/controller/dtos.py b/src/warchron/controller/dtos.py index 48e2749..d96df92 100644 --- a/src/warchron/controller/dtos.py +++ b/src/warchron/controller/dtos.py @@ -29,7 +29,7 @@ class WarDTO: class ObjectiveDTO: id: str name: str - description: str + description: str | None @dataclass(frozen=True, slots=True) @@ -63,9 +63,9 @@ class SectorDTO: id: str name: str round_index: int | None - major: str - minor: str - influence: str + major: str | None + minor: str | None + influence: str | None @dataclass @@ -78,8 +78,8 @@ class RoundDTO: class ChoiceDTO: id: str participant_name: str - priority_sector: str - secondary_sector: str + priority_sector: str | None + secondary_sector: str | None comment: str | None @@ -87,8 +87,8 @@ class ChoiceDTO: class BattleDTO: id: str sector_name: str - player_1: str - player_2: str + player_1: str | None + player_2: str | None winner: str | None score: str | None victory_condition: str | None diff --git a/src/warchron/controller/round_controller.py b/src/warchron/controller/round_controller.py index 41dde70..52c3201 100644 --- a/src/warchron/controller/round_controller.py +++ b/src/warchron/controller/round_controller.py @@ -1,13 +1,13 @@ from typing import List, TYPE_CHECKING -from PyQt6.QtWidgets import QDialog +from PyQt6.QtWidgets import QDialog, QMessageBox from warchron.constants import ItemType, RefreshScope if TYPE_CHECKING: from warchron.controller.app_controller import AppController from warchron.controller.dtos import ParticipantOption, SectorDTO, ChoiceDTO, BattleDTO - +from warchron.model.closure_service import ClosureService from warchron.view.choices_dialog import ChoicesDialog from warchron.view.battles_dialog import BattlesDialog @@ -92,6 +92,7 @@ class RoundController: ) ) self.app.view.display_round_battles(battles_for_display) + self.app.view.endRoundBtn.setEnabled(not rnd.is_over) def add_round(self) -> None: if not self.app.navigation.selected_campaign_id: @@ -102,6 +103,28 @@ class RoundController: RefreshScope.WARS_TREE, item_type=ItemType.ROUND, item_id=rnd.id ) + def close_round(self) -> None: + round_id = self.app.navigation.selected_round_id + if not round_id: + return + rnd = self.app.model.get_round(round_id) + if rnd.is_over: + return + try: + ties = ClosureService.close_round(rnd) + except RuntimeError as e: + QMessageBox.warning(self.app.view, "Cannot close round", str(e)) + return + if ties: + QMessageBox.information( + self.app.view, + "Tie detected", + "Round has unresolved ties. Resolution system not implemented yet.", + ) + return + self.app.is_dirty = True + self.app.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) + # Choice methods def edit_round_choice(self, choice_id: str) -> None: diff --git a/src/warchron/controller/war_controller.py b/src/warchron/controller/war_controller.py index acf786d..a29dd16 100644 --- a/src/warchron/controller/war_controller.py +++ b/src/warchron/controller/war_controller.py @@ -11,6 +11,7 @@ from warchron.controller.dtos import ( WarParticipantDTO, ObjectiveDTO, ) +from warchron.model.closure_service import ClosureService from warchron.view.war_dialog import WarDialog from warchron.view.objective_dialog import ObjectiveDialog from warchron.view.war_participant_dialog import WarParticipantDialog @@ -44,6 +45,7 @@ class WarController: for p in war_parts ] self.app.view.display_war_participants(participants_for_display) + self.app.view.endWarBtn.setEnabled(not war.is_over) def _validate_war_inputs(self, name: str, year: int) -> bool: if not name.strip(): @@ -86,6 +88,28 @@ class WarController: return self.app.model.update_war(war_id, name=name, year=year) + def close_war(self) -> None: + war_id = self.app.navigation.selected_war_id + if not war_id: + return + war = self.app.model.get_war(war_id) + if war.is_over: + return + try: + ties = ClosureService.close_war(war) + except RuntimeError as e: + QMessageBox.warning(self.app.view, "Cannot close war", str(e)) + return + if ties: + QMessageBox.information( + self.app.view, + "Tie detected", + "War has unresolved ties.", + ) + return + self.app.is_dirty = True + self.app.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) + def set_major_value(self, value: int) -> None: war_id = self.app.navigation.selected_war_id if not war_id: @@ -109,7 +133,7 @@ class WarController: # Objective methods - def _validate_objective_inputs(self, name: str, description: str) -> bool: + def _validate_objective_inputs(self, name: str, description: str | None) -> bool: if not name.strip(): QMessageBox.warning( self.app.view, "Invalid name", "Objective name cannot be empty." diff --git a/src/warchron/model/battle.py b/src/warchron/model/battle.py index df6e91b..219fc20 100644 --- a/src/warchron/model/battle.py +++ b/src/warchron/model/battle.py @@ -40,26 +40,30 @@ class Battle: def set_comment(self, new_comment: str | None) -> None: self.comment = new_comment + # TODO improve draw detection + def is_draw(self) -> bool: + return self.winner_id is None and self.score is not None + def toDict(self) -> Dict[str, Any]: return { "sector_id": self.sector_id, - "player_1_id": self.player_1_id, - "player_2_id": self.player_2_id, - "winner_id": self.winner_id, - "score": self.score, - "victory_condition": self.victory_condition, - "comment": self.comment, + "player_1_id": self.player_1_id or None, + "player_2_id": self.player_2_id or None, + "winner_id": self.winner_id or None, + "score": self.score or None, + "victory_condition": self.victory_condition or None, + "comment": self.comment or None, } @staticmethod def fromDict(data: Dict[str, Any]) -> Battle: battle = Battle( data["sector_id"], - data.get("player_1_id"), - data.get("player_2_id"), + data.get("player_1_id") or None, + data.get("player_2_id") or None, ) - battle.winner_id = data.get("winner_id") - battle.score = data.get("score") - battle.victory_condition = data.get("victory_condition") - battle.comment = data.get("comment") + battle.winner_id = data.get("winner_id") or None + battle.score = data.get("score") or None + battle.victory_condition = data.get("victory_condition") or None + battle.comment = data.get("comment") or None return battle diff --git a/src/warchron/model/campaign.py b/src/warchron/model/campaign.py index 82aea5c..4a847c0 100644 --- a/src/warchron/model/campaign.py +++ b/src/warchron/model/campaign.py @@ -146,7 +146,12 @@ class Campaign: ) def add_sector( - self, name: str, round_id: str, major_id: str, minor_id: str, influence_id: str + self, + name: str, + round_id: str | None, + major_id: str | None, + minor_id: str | None, + influence_id: str | None, ) -> Sector: sect = Sector(name, round_id, major_id, minor_id, influence_id) self.sectors[sect.id] = sect @@ -168,10 +173,10 @@ class Campaign: sector_id: str, *, name: str, - round_id: str, - major_id: str, - minor_id: str, - influence_id: str, + round_id: str | None, + major_id: str | None, + minor_id: str | None, + influence_id: str | None, ) -> None: sect = self.get_sector(sector_id) old_round_id = sect.round_id @@ -308,6 +313,9 @@ class Campaign: # Battle methods + def all_rounds_finished(self) -> bool: + return all(r.is_over for r in self.rounds) + def create_battle(self, round_id: str, sector_id: str) -> Battle: rnd = self.get_round(round_id) return rnd.create_battle(sector_id) diff --git a/src/warchron/model/choice.py b/src/warchron/model/choice.py index 9c1479c..f92107a 100644 --- a/src/warchron/model/choice.py +++ b/src/warchron/model/choice.py @@ -33,17 +33,17 @@ class Choice: def toDict(self) -> Dict[str, Any]: return { "participant_id": self.participant_id, - "priority_sector_id": self.priority_sector_id, - "secondary_sector_id": self.secondary_sector_id, - "comment": self.comment, + "priority_sector_id": self.priority_sector_id or None, + "secondary_sector_id": self.secondary_sector_id or None, + "comment": self.comment or None, } @staticmethod def fromDict(data: Dict[str, Any]) -> Choice: choice = Choice( data["participant_id"], - data.get("priority_sector_id"), - data.get("secondary_sector_id"), + data.get("priority_sector_id") or None, + data.get("secondary_sector_id") or None, ) - choice.comment = data.get("comment") + choice.comment = data.get("comment") or None return choice diff --git a/src/warchron/model/closure_service.py b/src/warchron/model/closure_service.py new file mode 100644 index 0000000..a3234a7 --- /dev/null +++ b/src/warchron/model/closure_service.py @@ -0,0 +1,83 @@ +from __future__ import annotations +from typing import List + +from warchron.constants import ContextType +from warchron.model.tie_manager import ResolutionContext +from warchron.model.war import War +from warchron.model.campaign import Campaign +from warchron.model.round import Round + + +class ClosureService: + + @staticmethod + def close_round(round: Round) -> List[ResolutionContext]: + if not round.all_battles_finished(): + raise RuntimeError("All battles must be finished to close their round") + ties = [] + for battle in round.battles.values(): + if battle.is_draw(): + participants: list[str] = [] + if battle.player_1_id is not None: + participants.append(battle.player_1_id) + if battle.player_2_id is not None: + participants.append(battle.player_2_id) + ties.append( + ResolutionContext( + context_type=ContextType.BATTLE, + context_id=battle.sector_id, + # TODO ref to War.participants at some point + participant_ids=participants, + ) + ) + if ties: + return ties + round.is_over = True + return [] + + @staticmethod + def close_campaign(campaign: Campaign) -> List[ResolutionContext]: + if not campaign.all_rounds_finished(): + raise RuntimeError("All rounds must be finished to close their campaign") + ties: List[ResolutionContext] = [] + # for round in campaign.rounds: + # # compute score + # # if participants have same score + # ties.append( + # ResolutionContext( + # context_type=ContextType.CAMPAIGN, + # context_id=campaign.id, + # participant_ids=[ + # # TODO ref to War.participants at some point + # campaign.participants[campaign_participant_id], + # campaign.participants[campaign_participant_id], + # ], + # ) + # ) + if ties: + return ties + campaign.is_over = True + return [] + + @staticmethod + def close_war(war: War) -> List[ResolutionContext]: + if not war.all_campaigns_finished(): + raise RuntimeError("All campaigns must be finished to close their war") + ties: List[ResolutionContext] = [] + # for campaign in war.campaigns: + # # compute score + # # if participants have same score + # ties.append( + # ResolutionContext( + # context_type=ContextType.WAR, + # context_id=war.id, + # participant_ids=[ + # war.participants[war_participant_id], + # war.participants[war_participant_id], + # ], + # ) + # ) + if ties: + return ties + war.is_over = True + return [] diff --git a/src/warchron/model/influence_service.py b/src/warchron/model/influence_service.py new file mode 100644 index 0000000..1ed4e4d --- /dev/null +++ b/src/warchron/model/influence_service.py @@ -0,0 +1,23 @@ +from warchron.model.war import War +from warchron.model.campaign import Campaign +from warchron.model.battle import Battle +from warchron.model.war_event import InfluenceGained + + +class InfluenceService: + + @staticmethod + def apply_battle_result(war: War, campaign: Campaign, battle: Battle) -> None: + if battle.winner_id is None: + return + sector = campaign.sectors[battle.sector_id] + # if sector grants influence + if sector.influence_objective_id and war.influence_token: + participant = war.participants[battle.winner_id] + participant.events.append( + InfluenceGained( + participant_id=participant.id, + amount=1, + source=f"battle:{battle.sector_id}", + ) + ) diff --git a/src/warchron/model/model.py b/src/warchron/model/model.py index 874d3fa..36ab496 100644 --- a/src/warchron/model/model.py +++ b/src/warchron/model/model.py @@ -182,7 +182,9 @@ class Model: # Objective methods - def add_objective(self, war_id: str, name: str, description: str) -> Objective: + def add_objective( + self, war_id: str, name: str, description: str | None + ) -> Objective: war = self.get_war(war_id) return war.add_objective(name, description) @@ -195,7 +197,7 @@ class Model: raise KeyError("Objective not found") def update_objective( - self, objective_id: str, *, name: str, description: str + self, objective_id: str, *, name: str, description: str | None ) -> None: war = self.get_war_by_objective(objective_id) war.update_objective(objective_id, name=name, description=description) @@ -229,6 +231,11 @@ class Model: def get_player_from_war_participant(self, war_part: WarParticipant) -> Player: return self.get_player(war_part.player_id) + def get_participant_name(self, participant_id: str) -> str: + war = self.get_war_by_war_participant(participant_id) + war_part = war.get_war_participant(participant_id) + return self.players[war_part.player_id].name + def update_war_participant(self, participant_id: str, *, faction: str) -> None: war = self.get_war_by_war_participant(participant_id) war.update_war_participant(participant_id, faction=faction) @@ -290,10 +297,10 @@ class Model: self, campaign_id: str, name: str, - round_id: str, - major_id: str, - minor_id: str, - influence_id: str, + round_id: str | None, + major_id: str | None, + minor_id: str | None, + influence_id: str | None, ) -> Sector: camp = self.get_campaign(campaign_id) return camp.add_sector(name, round_id, major_id, minor_id, influence_id) @@ -312,10 +319,10 @@ class Model: sector_id: str, *, name: str, - round_id: str, - major_id: str, - minor_id: str, - influence_id: str, + round_id: str | None, + major_id: str | None, + minor_id: str | None, + influence_id: str | None, ) -> None: war = self.get_war_by_sector(sector_id) war.update_sector( @@ -343,11 +350,6 @@ class Model: camp = self.get_campaign(camp_id) return camp.add_campaign_participant(player_id, leader, theme) - def get_participant_name(self, participant_id: str) -> str: - war = self.get_war_by_war_participant(participant_id) - war_part = war.get_war_participant(participant_id) - return self.players[war_part.player_id].name - # TODO replace multiloops by internal has_* method def get_campaign_participant(self, participant_id: str) -> CampaignParticipant: for war in self.wars.values(): diff --git a/src/warchron/model/objective.py b/src/warchron/model/objective.py index e203ab0..67029f9 100644 --- a/src/warchron/model/objective.py +++ b/src/warchron/model/objective.py @@ -4,10 +4,10 @@ from uuid import uuid4 class Objective: - def __init__(self, name: str, description: str): + def __init__(self, name: str, description: str | None): self.id: str = str(uuid4()) self.name: str = name - self.description: str = description + self.description: str | None = description def set_id(self, new_id: str) -> None: self.id = new_id @@ -15,18 +15,18 @@ class Objective: def set_name(self, new_name: str) -> None: self.name = new_name - def set_description(self, new_description: str) -> None: + def set_description(self, new_description: str | None) -> None: self.description = new_description def toDict(self) -> Dict[str, Any]: return { "id": self.id, "name": self.name, - "description": self.description, + "description": self.description or None, } @staticmethod def fromDict(data: Dict[str, Any]) -> Objective: - obj = Objective(data["name"], data["description"]) + obj = Objective(data["name"], data["description"] or None) obj.set_id(data["id"]) return obj diff --git a/src/warchron/model/round.py b/src/warchron/model/round.py index a8a1202..cb15f20 100644 --- a/src/warchron/model/round.py +++ b/src/warchron/model/round.py @@ -104,6 +104,11 @@ class Round: for bat in self.battles.values() ) + def all_battles_finished(self) -> bool: + return all( + b.winner_id is not None or b.is_draw() for b in self.battles.values() + ) + def create_battle(self, sector_id: str) -> Battle: if sector_id not in self.battles: battle = Battle(sector_id=sector_id, player_1_id=None, player_2_id=None) diff --git a/src/warchron/model/score_service.py b/src/warchron/model/score_service.py new file mode 100644 index 0000000..30bb48e --- /dev/null +++ b/src/warchron/model/score_service.py @@ -0,0 +1,39 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from warchron.model.war import War + + +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 + + @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 diff --git a/src/warchron/model/sector.py b/src/warchron/model/sector.py index 65e8895..3b76068 100644 --- a/src/warchron/model/sector.py +++ b/src/warchron/model/sector.py @@ -14,7 +14,7 @@ class Sector: ): self.id: str = str(uuid4()) self.name: str = name - self.round_id: str | None = round_id + self.round_id: str | None = round_id # ref to Campaign.rounds self.major_objective_id: str | None = major_id # ref to War.objectives self.minor_objective_id: str | None = minor_id # ref to War.objectives self.influence_objective_id: str | None = influence_id # ref to War.objectives @@ -27,16 +27,16 @@ class Sector: def set_name(self, new_name: str) -> None: self.name = new_name - def set_round(self, new_round_id: str) -> None: + def set_round(self, new_round_id: str | None) -> None: self.round_id = new_round_id - def set_major(self, new_major_id: str) -> None: + def set_major(self, new_major_id: str | None) -> None: self.major_objective_id = new_major_id - def set_minor(self, new_minor_id: str) -> None: + def set_minor(self, new_minor_id: str | None) -> None: self.minor_objective_id = new_minor_id - def set_influence(self, new_influence_id: str) -> None: + def set_influence(self, new_influence_id: str | None) -> None: self.influence_objective_id = new_influence_id def toDict(self) -> Dict[str, Any]: @@ -44,11 +44,11 @@ class Sector: "id": self.id, "name": self.name, "round_id": self.round_id, - "major_objective_id": self.major_objective_id, - "minor_objective_id": self.minor_objective_id, - "influence_objective_id": self.influence_objective_id, - "mission": self.mission, - "description": self.description, + "major_objective_id": self.major_objective_id or None, + "minor_objective_id": self.minor_objective_id or None, + "influence_objective_id": self.influence_objective_id or None, + "mission": self.mission or None, + "description": self.description or None, } @staticmethod @@ -56,11 +56,11 @@ class Sector: sec = Sector( data["name"], data["round_id"], - data.get("major_objective_id"), - data.get("minor_objective_id"), - data.get("influence_objective_id"), + data.get("major_objective_id") or None, + data.get("minor_objective_id") or None, + data.get("influence_objective_id") or None, ) sec.set_id(data["id"]) - sec.mission = data.get("mission") - sec.description = data.get("description") + sec.mission = data.get("mission") or None + sec.description = data.get("description") or None return sec diff --git a/src/warchron/model/tie_manager.py b/src/warchron/model/tie_manager.py new file mode 100644 index 0000000..84c81b4 --- /dev/null +++ b/src/warchron/model/tie_manager.py @@ -0,0 +1,46 @@ +from warchron.model.war import War +from warchron.model.war_event import InfluenceSpent + + +class ResolutionContext: + def __init__(self, context_type: str, context_id: str, participant_ids: list[str]): + self.context_type = context_type + self.context_id = context_id + self.participant_ids = participant_ids + + self.current_bids: dict[str, int] = {} + self.round_index: int = 0 + self.is_resolved: bool = False + + +class TieResolver: + + @staticmethod + def resolve( + war: War, + context: ResolutionContext, + bids: dict[str, int], + ) -> str | None: + # verify available token for each player + for pid, amount in bids.items(): + participant = war.participants[pid] + if participant.influence_tokens() < amount: + raise ValueError("Not enough influence tokens") + # apply spending + for pid, amount in bids.items(): + if amount > 0: + war.participants[pid].events.append( + InfluenceSpent( + participant_id=pid, + amount=amount, + context=context.context_type, + ) + ) + # determine winner + max_bid = max(bids.values()) + winners = [pid for pid, b in bids.items() if b == max_bid] + if len(winners) == 1: + context.is_resolved = True + return winners[0] + # persisting tie → None + return None diff --git a/src/warchron/model/war.py b/src/warchron/model/war.py index efc16ad..71cbe5c 100644 --- a/src/warchron/model/war.py +++ b/src/warchron/model/war.py @@ -86,7 +86,7 @@ class War: # Objective methods - def add_objective(self, name: str, description: str) -> Objective: + def add_objective(self, name: str, description: str | None) -> Objective: obj = Objective(name, description) self.objectives[obj.id] = obj return obj @@ -104,7 +104,7 @@ class War: return obj.name if obj else "" def update_objective( - self, objective_id: str, *, name: str, description: str + self, objective_id: str, *, name: str, description: str | None ) -> None: obj = self.get_objective(objective_id) obj.set_name(name) @@ -173,6 +173,9 @@ class War: def get_default_campaign_values(self) -> Dict[str, Any]: return {"month": datetime.now().month} + def all_campaigns_finished(self) -> bool: + return all(c.is_over for c in self.campaigns) + def add_campaign(self, name: str, month: int | None = None) -> Campaign: if month is None: month = self.get_default_campaign_values()["month"] @@ -247,10 +250,10 @@ class War: sector_id: str, *, name: str, - round_id: str, - major_id: str, - minor_id: str, - influence_id: str, + round_id: str | None, + major_id: str | None, + minor_id: str | None, + influence_id: str | None, ) -> None: camp = self.get_campaign_by_sector(sector_id) camp.update_sector( diff --git a/src/warchron/model/war_event.py b/src/warchron/model/war_event.py new file mode 100644 index 0000000..c4092c0 --- /dev/null +++ b/src/warchron/model/war_event.py @@ -0,0 +1,30 @@ +from datetime import datetime +from uuid import uuid4 + + +class WarEvent: + def __init__(self, participant_id: str): + self.id: str = str(uuid4()) + self.participant_id: str = participant_id + self.timestamp: datetime = datetime.now() + + +class TieResolved(WarEvent): + def __init__(self, participant_id: str, context_type: str, context_id: str): + super().__init__(participant_id) + self.context_type = context_type # battle, round, campaign, war + self.context_id = context_id + + +class InfluenceGained(WarEvent): + def __init__(self, participant_id: str, amount: int, source: str): + super().__init__(participant_id) + self.amount = amount + self.source = source # "battle", "tie_resolution", etc. + + +class InfluenceSpent(WarEvent): + def __init__(self, participant_id: str, amount: int, context: str): + super().__init__(participant_id) + self.amount = amount + self.context = context # "battle_tie", "campaign_tie", etc. diff --git a/src/warchron/model/war_participant.py b/src/warchron/model/war_participant.py index 0594bf4..78f304e 100644 --- a/src/warchron/model/war_participant.py +++ b/src/warchron/model/war_participant.py @@ -1,6 +1,12 @@ from __future__ import annotations from typing import Any, Dict from uuid import uuid4 +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from warchron.model.war import War +from warchron.model.war_event import WarEvent, InfluenceSpent, InfluenceGained +from warchron.model.score_service import ScoreService class WarParticipant: @@ -8,6 +14,7 @@ class WarParticipant: self.id: str = str(uuid4()) self.player_id: str = player_id # ref to Model.players self.faction: str = faction + self.events: list[WarEvent] = [] def set_id(self, new_id: str) -> None: self.id = new_id @@ -33,3 +40,16 @@ class WarParticipant: ) part.set_id(data["id"]) return part + + # Computed properties + + def influence_tokens(self) -> int: + gained = sum(e.amount for e in self.events if isinstance(e, InfluenceGained)) + spent = sum(e.amount for e in self.events if isinstance(e, InfluenceSpent)) + return gained - spent + + def victory_points(self, war: "War") -> int: + return ScoreService.compute_victory_points_for_participant(war, self.id) + + def narrative_points(self, war: "War") -> Dict[str, int]: + return ScoreService.compute_narrative_points_for_participant(war, self.id) diff --git a/src/warchron/view/battles_dialog.py b/src/warchron/view/battles_dialog.py index 2ef86a0..9e33255 100644 --- a/src/warchron/view/battles_dialog.py +++ b/src/warchron/view/battles_dialog.py @@ -49,20 +49,26 @@ class BattlesDialog(QDialog): def get_sector_id(self) -> str: return cast(str, self.ui.sectorComboBox.currentData()) - def get_player_1_id(self) -> str: - return cast(str, self.ui.player1ComboBox.currentData()) + def get_player_1_id(self) -> str | None: + text = cast(str, self.ui.player1ComboBox.currentData()) + return text if text else None - def get_player_2_id(self) -> str: - return cast(str, self.ui.player2ComboBox.currentData()) + def get_player_2_id(self) -> str | None: + text = cast(str, self.ui.player2ComboBox.currentData()) + return text if text else None - def get_winner_id(self) -> str: - return cast(str, self.ui.winnerComboBox.currentData()) + def get_winner_id(self) -> str | None: + text = cast(str, self.ui.winnerComboBox.currentData()) + return text if text else None - def get_score(self) -> str: - return self.ui.score.text().strip() + def get_score(self) -> str | None: + text = self.ui.score.text().strip() + return text if text else None - def get_victory_condition(self) -> str: - return self.ui.victoryCondition.text().strip() + def get_victory_condition(self) -> str | None: + text = self.ui.victoryCondition.text().strip() + return text if text else None - def get_comment(self) -> str: - return self.ui.battleComment.toPlainText().strip() + def get_comment(self) -> str | None: + text = self.ui.battleComment.toPlainText().strip() + return text if text else None diff --git a/src/warchron/view/choices_dialog.py b/src/warchron/view/choices_dialog.py index afaa79a..0c00e4f 100644 --- a/src/warchron/view/choices_dialog.py +++ b/src/warchron/view/choices_dialog.py @@ -38,11 +38,14 @@ class ChoicesDialog(QDialog): def get_participant_id(self) -> str: return cast(str, self.ui.playerComboBox.currentData()) - def get_priority_id(self) -> str: - return cast(str, self.ui.priorityComboBox.currentData()) + def get_priority_id(self) -> str | None: + text = cast(str, self.ui.priorityComboBox.currentData()) + return text if text else None - def get_secondary_id(self) -> str: - return cast(str, self.ui.secondaryComboBox.currentData()) + def get_secondary_id(self) -> str | None: + text = cast(str, self.ui.secondaryComboBox.currentData()) + return text if text else None - def get_comment(self) -> str: - return self.ui.choiceComment.toPlainText().strip() + def get_comment(self) -> str | None: + text = self.ui.choiceComment.toPlainText().strip() + return text if text else None diff --git a/src/warchron/view/objective_dialog.py b/src/warchron/view/objective_dialog.py index a414c8b..cd7da62 100644 --- a/src/warchron/view/objective_dialog.py +++ b/src/warchron/view/objective_dialog.py @@ -20,5 +20,6 @@ class ObjectiveDialog(QDialog): def get_objective_name(self) -> str: return self.ui.objectiveName.text().strip() - def get_objective_description(self) -> str: - return self.ui.objectiveDescription.toPlainText().strip() + def get_objective_description(self) -> str | None: + text = self.ui.objectiveDescription.toPlainText().strip() + return text if text else None diff --git a/src/warchron/view/sector_dialog.py b/src/warchron/view/sector_dialog.py index 66d0194..65e2802 100644 --- a/src/warchron/view/sector_dialog.py +++ b/src/warchron/view/sector_dialog.py @@ -42,14 +42,21 @@ class SectorDialog(QDialog): def get_sector_name(self) -> str: return self.ui.sectorName.text().strip() - def get_round_id(self) -> str: - return cast(str, self.ui.roundComboBox.currentData()) + def get_round_id(self) -> str | None: + text = cast(str, self.ui.roundComboBox.currentData()) + return text if text else None - def get_major_id(self) -> str: - return cast(str, self.ui.majorComboBox.currentData()) + def get_major_id(self) -> str | None: + text = cast(str, self.ui.majorComboBox.currentData()) + return text if text else None - def get_minor_id(self) -> str: - return cast(str, self.ui.minorComboBox.currentData()) + def get_minor_id(self) -> str | None: + text = cast(str, self.ui.minorComboBox.currentData()) + return text if text else None - def get_influence_id(self) -> str: - return cast(str, self.ui.influenceComboBox.currentData()) + def get_influence_id(self) -> str | None: + text = cast(str, self.ui.influenceComboBox.currentData()) + return text if text else None + + +# TODO fix mission + description missing diff --git a/src/warchron/view/ui/ui_main_window.py b/src/warchron/view/ui/ui_main_window.py index 5c43d0f..234c75f 100644 --- a/src/warchron/view/ui/ui_main_window.py +++ b/src/warchron/view/ui/ui_main_window.py @@ -369,7 +369,7 @@ class Ui_MainWindow(object): spacerItem15 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) self.horizontalLayout_13.addItem(spacerItem15) self.resolvePairingBtn = QtWidgets.QPushButton(parent=self.pageRound) - self.resolvePairingBtn.setEnabled(True) + self.resolvePairingBtn.setEnabled(False) self.resolvePairingBtn.setObjectName("resolvePairingBtn") self.horizontalLayout_13.addWidget(self.resolvePairingBtn) self.verticalLayout_8.addLayout(self.horizontalLayout_13) @@ -486,7 +486,7 @@ class Ui_MainWindow(object): self.retranslateUi(MainWindow) self.tabWidget.setCurrentIndex(1) - self.selectedDetailsStack.setCurrentIndex(1) + self.selectedDetailsStack.setCurrentIndex(3) QtCore.QMetaObject.connectSlotsByName(MainWindow) def retranslateUi(self, MainWindow): diff --git a/src/warchron/view/ui/ui_main_window.ui b/src/warchron/view/ui/ui_main_window.ui index 83c88b2..735b3c0 100644 --- a/src/warchron/view/ui/ui_main_window.ui +++ b/src/warchron/view/ui/ui_main_window.ui @@ -181,7 +181,7 @@ - 1 + 3 @@ -873,7 +873,7 @@ - true + false Resolve pairing