diff --git a/src/warchron/constants.py b/src/warchron/constants.py index d4f69e9..670a466 100644 --- a/src/warchron/constants.py +++ b/src/warchron/constants.py @@ -1,7 +1,6 @@ from enum import StrEnum from enum import Enum, auto from pathlib import Path -from typing import Dict from PyQt6.QtCore import Qt from PyQt6.QtGui import QIcon @@ -41,7 +40,7 @@ class IconName(str, Enum): class Icons: - _cache: Dict[str, QIcon] = {} + _cache: dict[str, QIcon] = {} _paths = { IconName.UNDO: "arrow-curve-180-left", diff --git a/src/warchron/controller/closure_workflow.py b/src/warchron/controller/closure_workflow.py deleted file mode 100644 index 71a1e3d..0000000 --- a/src/warchron/controller/closure_workflow.py +++ /dev/null @@ -1,77 +0,0 @@ -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from warchron.controller.app_controller import AppController - -from warchron.constants import ContextType -from warchron.model.exception import ForbiddenOperation -from warchron.model.war_event import TieResolved -from warchron.model.war import War -from warchron.model.campaign import Campaign -from warchron.model.battle import Battle -from warchron.model.round import Round -from warchron.model.closure_service import ClosureService -from warchron.model.tie_manager import TieResolver -from warchron.controller.dtos import TieContext - - -class ClosureWorkflow: - - def __init__(self, controller: "AppController"): - self.app = controller - - -class RoundClosureWorkflow(ClosureWorkflow): - - def start(self, war: War, campaign: Campaign, round: Round) -> None: - ClosureService.check_round_closable(round) - ties = TieResolver.find_round_ties(round, war) - while ties: - contexts = [ - RoundClosureWorkflow.build_battle_context(campaign, b) for b in ties - ] - resolvable = [] - for ctx in contexts: - if TieResolver.can_tie_be_resolved(war, ctx.participants): - resolvable.append(ctx) - else: - war.events.append( - TieResolved( - participant_id=None, - context_type=ctx.context_type, - context_id=ctx.context_id, - ) - ) - if not resolvable: - break - bids_map = self.app.rounds.resolve_ties(war, contexts) - for ctx in contexts: - bids = bids_map[ctx.context_id] - TieResolver.apply_bids( - war, - ctx.context_type, - ctx.context_id, - bids, - ) - TieResolver.try_tie_break( - war, - ctx.context_type, - ctx.context_id, - ctx.participants, - ) - ties = TieResolver.find_round_ties(round, war) - for battle in round.battles.values(): - ClosureService.apply_battle_outcomes(war, campaign, battle) - ClosureService.finalize_round(round) - - @staticmethod - def build_battle_context(campaign: Campaign, battle: Battle) -> TieContext: - if battle.player_1_id is None or battle.player_2_id is None: - raise ForbiddenOperation("Missing player(s) in this battle context.") - p1 = campaign.participants[battle.player_1_id].war_participant_id - p2 = campaign.participants[battle.player_2_id].war_participant_id - return TieContext( - context_type=ContextType.BATTLE, - context_id=battle.sector_id, - participants=[p1, p2], - ) diff --git a/src/warchron/controller/dtos.py b/src/warchron/controller/dtos.py index 454aa61..0e1b48a 100644 --- a/src/warchron/controller/dtos.py +++ b/src/warchron/controller/dtos.py @@ -3,8 +3,6 @@ from dataclasses import dataclass from PyQt6.QtGui import QIcon -from warchron.constants import ContextType - @dataclass(frozen=True) class ParticipantOption: @@ -105,10 +103,3 @@ class BattleDTO: state_icon: QIcon | None player1_icon: QIcon | None player2_icon: QIcon | None - - -@dataclass -class TieContext: - context_type: ContextType - context_id: str - participants: List[str] # war_participant_ids diff --git a/src/warchron/controller/round_controller.py b/src/warchron/controller/round_controller.py index 429d5ec..93e39ae 100644 --- a/src/warchron/controller/round_controller.py +++ b/src/warchron/controller/round_controller.py @@ -1,27 +1,16 @@ -from typing import List, Dict, TYPE_CHECKING +from typing import List, TYPE_CHECKING -from PyQt6.QtWidgets import QDialog -from PyQt6.QtWidgets import QMessageBox +from PyQt6.QtWidgets import QDialog, QMessageBox from warchron.constants import ItemType, RefreshScope, Icons, IconName -from warchron.model.exception import ForbiddenOperation, DomainError from warchron.model.round import Round -from warchron.model.war import War if TYPE_CHECKING: from warchron.controller.app_controller import AppController - -from warchron.controller.dtos import ( - ParticipantOption, - SectorDTO, - ChoiceDTO, - BattleDTO, - TieContext, -) -from warchron.controller.closure_workflow import RoundClosureWorkflow +from warchron.controller.dtos import ParticipantOption, SectorDTO, ChoiceDTO, BattleDTO +from warchron.model.closure_service import ClosureService from warchron.view.choice_dialog import ChoiceDialog from warchron.view.battle_dialog import BattleDialog -from warchron.view.tie_dialog import TieDialog class RoundController: @@ -134,47 +123,26 @@ class RoundController: if not round_id: return rnd = self.app.model.get_round(round_id) - camp = self.app.model.get_campaign_by_round(round_id) - war = self.app.model.get_war_by_round(round_id) - workflow = RoundClosureWorkflow(self.app) + if rnd.is_over: + return try: - workflow.start(war, camp, rnd) - except DomainError as e: - QMessageBox.warning( + 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, - "Deletion forbidden", - str(e), + "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) self.app.navigation.refresh_and_select( RefreshScope.WARS_TREE, item_type=ItemType.ROUND, item_id=round_id ) - def resolve_ties( - self, war: War, contexts: List[TieContext] - ) -> Dict[str, Dict[str, bool]]: - bids_map = {} - for ctx in contexts: - players = [ - ParticipantOption( - id=pid, - name=self.app.model.get_participant_name(pid), - ) - for pid in ctx.participants - ] - counters = [war.get_influence_tokens(pid) for pid in ctx.participants] - dialog = TieDialog( - parent=self.app.view, - players=players, - counters=counters, - context_id=ctx.context_id, - ) - if not dialog.exec(): - raise ForbiddenOperation("Tie resolution cancelled") - bids_map[ctx.context_id] = dialog.get_bids() - return bids_map - # Choice methods def edit_round_choice(self, choice_id: str) -> None: diff --git a/src/warchron/model/battle.py b/src/warchron/model/battle.py index 1af9b2b..1b4a0e4 100644 --- a/src/warchron/model/battle.py +++ b/src/warchron/model/battle.py @@ -19,17 +19,17 @@ class Battle: self.victory_condition: str | None = None self.comment: str | None = None - def set_sector(self, new_sector_id: str) -> None: - self.sector_id = new_sector_id + def set_id(self, new_id: str) -> None: + self.sector_id = new_id - def set_player_1(self, new_camp_part_id: str | None) -> None: - self.player_1_id = new_camp_part_id + def set_player_1(self, new_player_id: str | None) -> None: + self.player_1_id = new_player_id - def set_player_2(self, new_camp_part_id: str | None) -> None: - self.player_2_id = new_camp_part_id + def set_player_2(self, new_player_id: str | None) -> None: + self.player_2_id = new_player_id - def set_winner(self, new_camp_part_id: str | None) -> None: - self.winner_id = new_camp_part_id + def set_winner(self, new_player_id: str | None) -> None: + self.winner_id = new_player_id def set_score(self, new_score: str | None) -> None: self.score = new_score diff --git a/src/warchron/model/campaign_participant.py b/src/warchron/model/campaign_participant.py index 5ccea4e..d7fbc63 100644 --- a/src/warchron/model/campaign_participant.py +++ b/src/warchron/model/campaign_participant.py @@ -15,8 +15,8 @@ class CampaignParticipant: def set_id(self, new_id: str) -> None: self.id = new_id - def set_war_participant(self, new_war_part_id: str) -> None: - self.war_participant_id = new_war_part_id + def set_war_participant(self, new_participant: str) -> None: + self.war_participant_id = new_participant def set_leader(self, new_faction: str) -> None: self.leader = new_faction diff --git a/src/warchron/model/choice.py b/src/warchron/model/choice.py index 0ed6aee..f92107a 100644 --- a/src/warchron/model/choice.py +++ b/src/warchron/model/choice.py @@ -18,8 +18,8 @@ class Choice: ) self.comment: str | None = None - def set_participant(self, new_camp_part_id: str) -> None: - self.participant_id = new_camp_part_id + def set_id(self, new_id: str) -> None: + self.participant_id = new_id def set_priority(self, new_priority_id: str | None) -> None: self.priority_sector_id = new_priority_id diff --git a/src/warchron/model/closure_service.py b/src/warchron/model/closure_service.py index fb31372..a3234a7 100644 --- a/src/warchron/model/closure_service.py +++ b/src/warchron/model/closure_service.py @@ -2,69 +2,44 @@ from __future__ import annotations from typing import List from warchron.constants import ContextType -from warchron.model.exception import ForbiddenOperation -from warchron.model.tie_manager import TieResolver -from warchron.model.war_event import InfluenceGained +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 -from warchron.model.battle import Battle class ClosureService: - # Round methods - @staticmethod - def check_round_closable(round: Round) -> None: - if round.is_over: - raise ForbiddenOperation("Round already closed") + def close_round(round: Round) -> List[ResolutionContext]: 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: - already_granted = any( - isinstance(e, InfluenceGained) and e.source == f"battle:{battle.sector_id}" - for e in war.events - ) - if already_granted: - return - 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, - battle.sector_id, - base_winner, - ) - if effective_winner is None: - return - sector = campaign.sectors[battle.sector_id] - if sector.influence_objective_id and war.influence_token: - war.events.append( - InfluenceGained( - participant_id=effective_winner, - amount=1, - source=f"battle:{battle.sector_id}", + 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, + ) ) - ) - - @staticmethod - def finalize_round(round: Round) -> None: + if ties: + return ties round.is_over = True - - # Campaign methods + return [] @staticmethod - def close_campaign(campaign: Campaign) -> List[str]: + 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[str] = [] + ties: List[ResolutionContext] = [] # for round in campaign.rounds: # # compute score # # if participants have same score @@ -84,13 +59,11 @@ class ClosureService: campaign.is_over = True return [] - # War methods - @staticmethod - def close_war(war: War) -> List[str]: + 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[str] = [] + ties: List[ResolutionContext] = [] # for campaign in war.campaigns: # # compute score # # if participants have same score diff --git a/src/warchron/model/closure_workflow.py b/src/warchron/model/closure_workflow.py new file mode 100644 index 0000000..db7ee66 --- /dev/null +++ b/src/warchron/model/closure_workflow.py @@ -0,0 +1,19 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from warchron.model.war import War + from warchron.model.war import War + from warchron.model.war import War + from warchron.model.closure_service import ClosureService + + +class RoundClosureWorkflow: + + def close_round(self, round_id): + rnd = repo.get_round(round_id) + + ties = ClosureService.close_round(rnd) + + repo.save() + + return ties 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/score_service.py b/src/warchron/model/score_service.py index 298139f..2bfda9b 100644 --- a/src/warchron/model/score_service.py +++ b/src/warchron/model/score_service.py @@ -1,4 +1,4 @@ -from typing import Dict, TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from warchron.model.war import War @@ -23,8 +23,8 @@ class ScoreService: @staticmethod def compute_narrative_points_for_participant( war: "War", participant_id: str - ) -> Dict[str, int]: - totals: Dict[str, int] = {} + ) -> dict[str, int]: + totals: dict[str, int] = {} for obj_id in war.objectives: totals[obj_id] = 0 for campaign in war.campaigns: diff --git a/src/warchron/model/tie_manager.py b/src/warchron/model/tie_manager.py index da031f9..84c81b4 100644 --- a/src/warchron/model/tie_manager.py +++ b/src/warchron/model/tie_manager.py @@ -1,108 +1,46 @@ -from typing import List, Dict - -from warchron.constants import ContextType -from warchron.model.exception import ForbiddenOperation from warchron.model.war import War -from warchron.model.round import Round -from warchron.model.battle import Battle -from warchron.model.war_event import InfluenceSpent, TieResolved +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 find_round_ties(round: Round, war: War) -> List[Battle]: - ties = [] - for battle in round.battles.values(): - if not battle.is_draw(): - continue - resolved = any( - isinstance(e, TieResolved) - and e.context_type == ContextType.BATTLE - and e.context_id == battle.sector_id - for e in war.events - ) - if not resolved: - ties.append(battle) - return ties - - @staticmethod - def apply_bids( + def resolve( war: War, - context_type: ContextType, - context_id: str, - bids: Dict[str, bool], # war_participant_id -> spend? - ) -> None: - for war_part_id, spend in bids.items(): - if not spend: - continue - if war.get_influence_tokens(war_part_id) < 1: - raise ForbiddenOperation("Not enough tokens") - war.events.append( - InfluenceSpent( - participant_id=war_part_id, - amount=1, - context_type=context_type, - ) - ) - - @staticmethod - def try_tie_break( - war: War, - context_type: ContextType, - context_id: str, - participants: List[str], # war_participant_ids - ) -> bool: - spent: Dict[str, int] = {} - for war_part_id in participants: - spent[war_part_id] = sum( - e.amount - for e in war.events - if isinstance(e, InfluenceSpent) - and e.participant_id == war_part_id - and e.context_type == context_type - ) - values = set(spent.values()) - if values == {0}: # no bid = confirmed draw - war.events.append( - TieResolved( - participant_id=None, - context_type=context_type, - context_id=context_id, - ) - ) - return True - if len(values) == 1: # tie again, continue - return False - winner = max(spent.items(), key=lambda item: item[1])[0] - war.events.append( - TieResolved( - participant_id=winner, - context_type=context_type, - context_id=context_id, - ) - ) - return True - - @staticmethod - def can_tie_be_resolved(war: War, participants: List[str]) -> bool: - return any(war.get_influence_tokens(pid) > 0 for pid in participants) - - @staticmethod - def get_effective_winner_id( - war: War, - context_type: ContextType, - context_id: str, - base_winner_id: str | None, + context: ResolutionContext, + bids: dict[str, int], ) -> str | None: - if base_winner_id is not None: - return base_winner_id - for ev in reversed(war.events): - if ( - isinstance(ev, TieResolved) - and ev.context_type == context_type - and ev.context_id == context_id - ): - return ev.participant_id # None if confirmed draw - + # 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 93e9cc5..1f7d068 100644 --- a/src/warchron/model/war.py +++ b/src/warchron/model/war.py @@ -3,7 +3,6 @@ from uuid import uuid4 from datetime import datetime from typing import Any, Dict, List -from warchron.model.war_event import WarEvent, InfluenceGained, InfluenceSpent from warchron.model.exception import ForbiddenOperation from warchron.model.war_participant import WarParticipant from warchron.model.objective import Objective @@ -26,7 +25,6 @@ class War: self.participants: Dict[str, WarParticipant] = {} self.objectives: Dict[str, Objective] = {} self.campaigns: List[Campaign] = [] - self.events: List[WarEvent] = [] self.is_over: bool = False def set_id(self, new_id: str) -> None: @@ -55,9 +53,6 @@ class War: def set_influence_token(self, new_state: bool) -> None: if self.is_over: raise ForbiddenOperation("Can't set influence token of a closed war.") - # TODO raise RequiresConfirmation - # * disable: cleanup if any token has already been gained/spent - # * enable: retrigger battle_outcomes and resolve tie again if any draw self.influence_token = new_state def set_state(self, new_state: bool) -> None: @@ -74,7 +69,6 @@ class War: "participants": [part.toDict() for part in self.participants.values()], "objectives": [obj.toDict() for obj in self.objectives.values()], "campaigns": [camp.toDict() for camp in self.campaigns], - "events": [ev.toDict() for ev in self.events], "is_over": self.is_over, } @@ -93,8 +87,6 @@ class War: war.objectives[obj.id] = obj for camp_data in data.get("campaigns", []): war.campaigns.append(Campaign.fromDict(camp_data)) - for ev_data in data.get("events", []): - war.events.append(WarEvent.fromDict(ev_data)) war.set_state(data.get("is_over", False)) return war @@ -242,13 +234,11 @@ class War: return camp raise KeyError(f"Sector {sector_id} not found in any Campaign") - def get_campaign_by_campaign_participant( - self, participant_id: str - ) -> Campaign | None: + def get_campaign_by_campaign_participant(self, participant_id: str) -> Campaign: for camp in self.campaigns: if camp.has_participant(participant_id): return camp - return None + raise KeyError(f"Participant {participant_id} not found in any Campaign") def update_campaign(self, campaign_id: str, *, name: str, month: int) -> None: if self.is_over: @@ -361,13 +351,11 @@ class War: self, participant_id: str, *, leader: str, theme: str ) -> None: camp = self.get_campaign_by_campaign_participant(participant_id) - if camp is not None: - camp.update_campaign_participant(participant_id, leader=leader, theme=theme) + camp.update_campaign_participant(participant_id, leader=leader, theme=theme) def remove_campaign_participant(self, participant_id: str) -> None: camp = self.get_campaign_by_campaign_participant(participant_id) - if camp is not None: - camp.remove_campaign_participant(participant_id) + camp.remove_campaign_participant(participant_id) # Round methods @@ -447,18 +435,3 @@ class War: camp = self.get_campaign_by_round(round_id) if camp is not None: camp.remove_battle(round_id, sector_id) - - # Event methods - - def get_influence_tokens(self, participant_id: str) -> int: - gained = sum( - e.amount - for e in self.events - if isinstance(e, InfluenceGained) and e.participant_id == participant_id - ) - spent = sum( - e.amount - for e in self.events - if isinstance(e, InfluenceSpent) and e.participant_id == participant_id - ) - return gained - spent diff --git a/src/warchron/model/war_event.py b/src/warchron/model/war_event.py index 72c03f3..c4092c0 100644 --- a/src/warchron/model/war_event.py +++ b/src/warchron/model/war_event.py @@ -1,143 +1,30 @@ -from __future__ import annotations -from typing import Dict, Any, TypeVar, Type, cast from datetime import datetime from uuid import uuid4 -T = TypeVar("T", bound="WarEvent") -EVENT_REGISTRY: Dict[str, Type["WarEvent"]] = {} - - -def register_event(cls: Type[T]) -> Type[T]: - EVENT_REGISTRY[cls.TYPE] = cls - return cls - class WarEvent: - TYPE = "WarEvent" - - def __init__(self, participant_id: str | None = None): + def __init__(self, participant_id: str): self.id: str = str(uuid4()) - self.participant_id: str | None = participant_id + self.participant_id: str = participant_id self.timestamp: datetime = datetime.now() - def set_id(self, new_id: str) -> None: - self.id = new_id - def set_participant(self, new_war_part_id: str | None) -> None: - self.participant_id = new_war_part_id - - def set_timestamp(self, new_timestamp: datetime) -> None: - self.timestamp = new_timestamp - - def toDict(self) -> Dict[str, Any]: - return { - "type": self.TYPE, - "id": self.id, - "participant_id": self.participant_id, - "timestamp": self.timestamp.isoformat(), - } - - @classmethod - def _base_fromDict(cls: Type[T], ev: T, data: Dict[str, Any]) -> T: - ev.id = data["id"] - ev.participant_id = data["participant_id"] - ev.timestamp = datetime.fromisoformat(data["timestamp"]) - return ev - - @staticmethod - def fromDict(data: Dict[str, Any]) -> "WarEvent": - ev_type = data["type"] - cls = cast(Type[WarEvent], EVENT_REGISTRY.get(ev_type)) - if cls is None: - raise ValueError(f"Unknown event type: {ev_type}") - return cls.fromDict(data) - - -@register_event class TieResolved(WarEvent): - TYPE = "TieResolved" - - def __init__(self, participant_id: str | None, context_type: str, context_id: str): + def __init__(self, participant_id: str, context_type: str, context_id: str): super().__init__(participant_id) - self.participant_id: str | None = ( - participant_id # winner or None (confirmed tie) - ) self.context_type = context_type # battle, round, campaign, war self.context_id = context_id - def toDict(self) -> Dict[str, Any]: - d = super().toDict() - d.update( - { - "context_type": self.context_type, - "context_id": self.context_id, - } - ) - return d - @classmethod - def fromDict(cls, data: Dict[str, Any]) -> TieResolved: - ev = cls( - data["participant_id"], - data["context_type"], - data["context_id"], - ) - return cls._base_fromDict(ev, data) - - -@register_event class InfluenceGained(WarEvent): - TYPE = "InfluenceGained" - def __init__(self, participant_id: str, amount: int, source: str): super().__init__(participant_id) self.amount = amount self.source = source # "battle", "tie_resolution", etc. - def toDict(self) -> Dict[str, Any]: - d = super().toDict() - d.update( - { - "amount": self.amount, - "source": self.source, - } - ) - return d - @classmethod - def fromDict(cls, data: Dict[str, Any]) -> InfluenceGained: - ev = cls( - data["participant_id"], - data["amount"], - data["source"], - ) - return cls._base_fromDict(ev, data) - - -@register_event class InfluenceSpent(WarEvent): - TYPE = "InfluenceSpent" - - def __init__(self, participant_id: str, amount: int, context_type: str): + def __init__(self, participant_id: str, amount: int, context: str): super().__init__(participant_id) self.amount = amount - self.context_type = context_type # "battle_tie", "campaign_tie", etc. - - def toDict(self) -> Dict[str, Any]: - d = super().toDict() - d.update( - { - "amount": self.amount, - "context_type": self.context_type, - } - ) - return d - - @classmethod - def fromDict(cls, data: Dict[str, Any]) -> InfluenceSpent: - ev = cls( - data["participant_id"], - data["amount"], - data["context_type"], - ) - return cls._base_fromDict(ev, data) + 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 8708572..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,12 +14,13 @@ 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 - def set_player(self, new_player_id: str) -> None: - self.player_id = new_player_id + def set_player(self, new_player: str) -> None: + self.player_id = new_player def set_faction(self, new_faction: str) -> None: self.faction = new_faction @@ -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/tie_dialog.py b/src/warchron/view/tie_dialog.py deleted file mode 100644 index 495033c..0000000 --- a/src/warchron/view/tie_dialog.py +++ /dev/null @@ -1,40 +0,0 @@ -from typing import List, Dict - -from PyQt6.QtWidgets import QWidget, QDialog - -from warchron.constants import Icons, IconName -from warchron.controller.dtos import ParticipantOption -from warchron.view.ui.ui_tie_dialog import Ui_tieDialog - - -class TieDialog(QDialog): - def __init__( - self, - parent: QWidget | None = None, - *, - players: List[ParticipantOption], - counters: List[int], - context_id: str, - ) -> None: - super().__init__(parent) - self._context_id = context_id - self._p1_id = players[0].id - self._p2_id = players[1].id - self.ui: Ui_tieDialog = Ui_tieDialog() - self.ui.setupUi(self) # type: ignore - self.ui.tieContext.setText("Battle tie") # Change with context - self.ui.groupBox_1.setTitle(players[0].name) - self.ui.groupBox_2.setTitle(players[1].name) - self.ui.tokenCount_1.setText(str(counters[0])) - self.ui.tokenCount_2.setText(str(counters[1])) - if counters[0] < 1: - self.ui.tokenSpend_1.setDisabled(True) - if counters[1] < 1: - self.ui.tokenSpend_2.setDisabled(True) - self.setWindowIcon(Icons.get(IconName.WARCHRON)) - - def get_bids(self) -> Dict[str, bool]: - return { - self._p1_id: self.ui.tokenSpend_1.isChecked(), - self._p2_id: self.ui.tokenSpend_2.isChecked(), - } diff --git a/src/warchron/view/ui/ui_battle_dialog.py b/src/warchron/view/ui/ui_battle_dialog.py index c55f4c5..7e50a17 100644 --- a/src/warchron/view/ui/ui_battle_dialog.py +++ b/src/warchron/view/ui/ui_battle_dialog.py @@ -117,7 +117,7 @@ class Ui_battleDialog(object): def retranslateUi(self, battleDialog): _translate = QtCore.QCoreApplication.translate - battleDialog.setWindowTitle(_translate("battleDialog", "Battle")) + battleDialog.setWindowTitle(_translate("battleDialog", "Battle result")) self.label_7.setText(_translate("battleDialog", "Sector")) self.label_5.setText(_translate("battleDialog", "Player 1")) self.label_6.setText(_translate("battleDialog", "Player 2")) diff --git a/src/warchron/view/ui/ui_battle_dialog.ui b/src/warchron/view/ui/ui_battle_dialog.ui index 4fbe3ac..47c1ffb 100644 --- a/src/warchron/view/ui/ui_battle_dialog.ui +++ b/src/warchron/view/ui/ui_battle_dialog.ui @@ -14,7 +14,7 @@ - Battle + Battle result diff --git a/src/warchron/view/ui/ui_choice_dialog.py b/src/warchron/view/ui/ui_choice_dialog.py index 4c1deaa..cffcef6 100644 --- a/src/warchron/view/ui/ui_choice_dialog.py +++ b/src/warchron/view/ui/ui_choice_dialog.py @@ -71,7 +71,7 @@ class Ui_choiceDialog(object): def retranslateUi(self, choiceDialog): _translate = QtCore.QCoreApplication.translate - choiceDialog.setWindowTitle(_translate("choiceDialog", "Choice")) + choiceDialog.setWindowTitle(_translate("choiceDialog", "Choices")) self.label.setText(_translate("choiceDialog", "Player")) self.label_2.setText(_translate("choiceDialog", "Priority")) self.label_3.setText(_translate("choiceDialog", "Secondary")) diff --git a/src/warchron/view/ui/ui_choice_dialog.ui b/src/warchron/view/ui/ui_choice_dialog.ui index 24142ec..a6eff58 100644 --- a/src/warchron/view/ui/ui_choice_dialog.ui +++ b/src/warchron/view/ui/ui_choice_dialog.ui @@ -14,7 +14,7 @@ - Choice + Choices diff --git a/src/warchron/view/ui/ui_tie_dialog.py b/src/warchron/view/ui/ui_tie_dialog.py deleted file mode 100644 index 1bfc512..0000000 --- a/src/warchron/view/ui/ui_tie_dialog.py +++ /dev/null @@ -1,118 +0,0 @@ -# Form implementation generated from reading ui file '.\src\warchron\view\ui\ui_tie_dialog.ui' -# -# Created by: PyQt6 UI code generator 6.7.1 -# -# WARNING: Any manual changes made to this file will be lost when pyuic6 is -# run again. Do not edit this file unless you know what you are doing. - - -from PyQt6 import QtCore, QtGui, QtWidgets - - -class Ui_tieDialog(object): - def setupUi(self, tieDialog): - tieDialog.setObjectName("tieDialog") - tieDialog.setWindowModality(QtCore.Qt.WindowModality.ApplicationModal) - tieDialog.resize(477, 174) - icon = QtGui.QIcon() - icon.addPixmap(QtGui.QPixmap(".\\src\\warchron\\view\\ui\\../resources/warchron_logo.png"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off) - tieDialog.setWindowIcon(icon) - self.verticalLayout_5 = QtWidgets.QVBoxLayout(tieDialog) - self.verticalLayout_5.setObjectName("verticalLayout_5") - self.horizontalLayout_3 = QtWidgets.QHBoxLayout() - self.horizontalLayout_3.setObjectName("horizontalLayout_3") - self.tieContext = QtWidgets.QLabel(parent=tieDialog) - font = QtGui.QFont() - font.setPointSize(10) - font.setBold(True) - font.setWeight(75) - self.tieContext.setFont(font) - self.tieContext.setObjectName("tieContext") - self.horizontalLayout_3.addWidget(self.tieContext) - spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) - self.horizontalLayout_3.addItem(spacerItem) - self.verticalLayout_5.addLayout(self.horizontalLayout_3) - self.horizontalLayout_4 = QtWidgets.QHBoxLayout() - self.horizontalLayout_4.setObjectName("horizontalLayout_4") - self.groupBox_1 = QtWidgets.QGroupBox(parent=tieDialog) - self.groupBox_1.setObjectName("groupBox_1") - self.horizontalLayout = QtWidgets.QHBoxLayout(self.groupBox_1) - self.horizontalLayout.setObjectName("horizontalLayout") - self.verticalLayout = QtWidgets.QVBoxLayout() - self.verticalLayout.setObjectName("verticalLayout") - self.label_5 = QtWidgets.QLabel(parent=self.groupBox_1) - self.label_5.setObjectName("label_5") - self.verticalLayout.addWidget(self.label_5) - self.label_2 = QtWidgets.QLabel(parent=self.groupBox_1) - self.label_2.setObjectName("label_2") - self.verticalLayout.addWidget(self.label_2) - self.horizontalLayout.addLayout(self.verticalLayout) - self.verticalLayout_2 = QtWidgets.QVBoxLayout() - self.verticalLayout_2.setObjectName("verticalLayout_2") - self.tokenSpend_1 = QtWidgets.QCheckBox(parent=self.groupBox_1) - self.tokenSpend_1.setText("") - self.tokenSpend_1.setObjectName("tokenSpend_1") - self.verticalLayout_2.addWidget(self.tokenSpend_1) - self.tokenCount_1 = QtWidgets.QLineEdit(parent=self.groupBox_1) - self.tokenCount_1.setEnabled(False) - self.tokenCount_1.setObjectName("tokenCount_1") - self.verticalLayout_2.addWidget(self.tokenCount_1) - self.horizontalLayout.addLayout(self.verticalLayout_2) - self.horizontalLayout_4.addWidget(self.groupBox_1) - self.groupBox_2 = QtWidgets.QGroupBox(parent=tieDialog) - self.groupBox_2.setObjectName("groupBox_2") - self.horizontalLayout_2 = QtWidgets.QHBoxLayout(self.groupBox_2) - self.horizontalLayout_2.setObjectName("horizontalLayout_2") - self.verticalLayout_3 = QtWidgets.QVBoxLayout() - self.verticalLayout_3.setObjectName("verticalLayout_3") - self.label_6 = QtWidgets.QLabel(parent=self.groupBox_2) - self.label_6.setObjectName("label_6") - self.verticalLayout_3.addWidget(self.label_6) - self.label_3 = QtWidgets.QLabel(parent=self.groupBox_2) - self.label_3.setObjectName("label_3") - self.verticalLayout_3.addWidget(self.label_3) - self.horizontalLayout_2.addLayout(self.verticalLayout_3) - self.verticalLayout_4 = QtWidgets.QVBoxLayout() - self.verticalLayout_4.setObjectName("verticalLayout_4") - self.tokenSpend_2 = QtWidgets.QCheckBox(parent=self.groupBox_2) - self.tokenSpend_2.setText("") - self.tokenSpend_2.setObjectName("tokenSpend_2") - self.verticalLayout_4.addWidget(self.tokenSpend_2) - self.tokenCount_2 = QtWidgets.QLineEdit(parent=self.groupBox_2) - self.tokenCount_2.setEnabled(False) - self.tokenCount_2.setObjectName("tokenCount_2") - self.verticalLayout_4.addWidget(self.tokenCount_2) - self.horizontalLayout_2.addLayout(self.verticalLayout_4) - self.horizontalLayout_4.addWidget(self.groupBox_2) - self.verticalLayout_5.addLayout(self.horizontalLayout_4) - self.buttonBox = QtWidgets.QDialogButtonBox(parent=tieDialog) - self.buttonBox.setOrientation(QtCore.Qt.Orientation.Horizontal) - self.buttonBox.setStandardButtons(QtWidgets.QDialogButtonBox.StandardButton.Cancel|QtWidgets.QDialogButtonBox.StandardButton.Ok) - self.buttonBox.setObjectName("buttonBox") - self.verticalLayout_5.addWidget(self.buttonBox) - - self.retranslateUi(tieDialog) - self.buttonBox.accepted.connect(tieDialog.accept) # type: ignore - self.buttonBox.rejected.connect(tieDialog.reject) # type: ignore - QtCore.QMetaObject.connectSlotsByName(tieDialog) - - def retranslateUi(self, tieDialog): - _translate = QtCore.QCoreApplication.translate - tieDialog.setWindowTitle(_translate("tieDialog", "Tie")) - self.tieContext.setText(_translate("tieDialog", "Battle tie")) - self.groupBox_1.setTitle(_translate("tieDialog", "Player 1")) - self.label_5.setText(_translate("tieDialog", "Spend token")) - self.label_2.setText(_translate("tieDialog", "Remaining token(s)")) - self.groupBox_2.setTitle(_translate("tieDialog", "Player 2")) - self.label_6.setText(_translate("tieDialog", "Spend token")) - self.label_3.setText(_translate("tieDialog", "Remaining token(s)")) - - -if __name__ == "__main__": - import sys - app = QtWidgets.QApplication(sys.argv) - tieDialog = QtWidgets.QDialog() - ui = Ui_tieDialog() - ui.setupUi(tieDialog) - tieDialog.show() - sys.exit(app.exec()) diff --git a/src/warchron/view/ui/ui_tie_dialog.ui b/src/warchron/view/ui/ui_tie_dialog.ui deleted file mode 100644 index ccceb54..0000000 --- a/src/warchron/view/ui/ui_tie_dialog.ui +++ /dev/null @@ -1,196 +0,0 @@ - - - tieDialog - - - Qt::ApplicationModal - - - - 0 - 0 - 477 - 174 - - - - Tie - - - - ../resources/warchron_logo.png../resources/warchron_logo.png - - - - - - - - - 10 - 75 - true - - - - Battle tie - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - - Player 1 - - - - - - - - Spend token - - - - - - - Remaining token(s) - - - - - - - - - - - - - - - - - - false - - - - - - - - - - - - Player 2 - - - - - - - - Spend token - - - - - - - Remaining token(s) - - - - - - - - - - - - - - - - - - false - - - - - - - - - - - - - - Qt::Horizontal - - - QDialogButtonBox::Cancel|QDialogButtonBox::Ok - - - - - - - - - buttonBox - accepted() - tieDialog - accept() - - - 248 - 254 - - - 157 - 274 - - - - - buttonBox - rejected() - tieDialog - reject() - - - 316 - 260 - - - 286 - 274 - - - - - diff --git a/src/warchron/view/view.py b/src/warchron/view/view.py index 15e1e29..5950dae 100644 --- a/src/warchron/view/view.py +++ b/src/warchron/view/view.py @@ -1,5 +1,5 @@ from __future__ import annotations -from typing import Callable, List, Dict +from typing import Callable, List from pathlib import Path import calendar @@ -288,7 +288,7 @@ class View(QtWidgets.QMainWindow, Ui_MainWindow): if item is not None and walk(item): return - def get_selected_tree_item(self) -> Dict[str, str] | None: + def get_selected_tree_item(self) -> dict[str, str] | None: item = self.warsTree.currentItem() if not item: return None