diff --git a/src/warchron/constants.py b/src/warchron/constants.py index 670a466..d4f69e9 100644 --- a/src/warchron/constants.py +++ b/src/warchron/constants.py @@ -1,6 +1,7 @@ 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 @@ -40,7 +41,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 new file mode 100644 index 0000000..71a1e3d --- /dev/null +++ b/src/warchron/controller/closure_workflow.py @@ -0,0 +1,77 @@ +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 0e1b48a..454aa61 100644 --- a/src/warchron/controller/dtos.py +++ b/src/warchron/controller/dtos.py @@ -3,6 +3,8 @@ from dataclasses import dataclass from PyQt6.QtGui import QIcon +from warchron.constants import ContextType + @dataclass(frozen=True) class ParticipantOption: @@ -103,3 +105,10 @@ 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 93e39ae..429d5ec 100644 --- a/src/warchron/controller/round_controller.py +++ b/src/warchron/controller/round_controller.py @@ -1,16 +1,27 @@ -from typing import List, TYPE_CHECKING +from typing import List, Dict, TYPE_CHECKING -from PyQt6.QtWidgets import QDialog, QMessageBox +from PyQt6.QtWidgets import QDialog +from PyQt6.QtWidgets import 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 -from warchron.model.closure_service import ClosureService + +from warchron.controller.dtos import ( + ParticipantOption, + SectorDTO, + ChoiceDTO, + BattleDTO, + TieContext, +) +from warchron.controller.closure_workflow import RoundClosureWorkflow from warchron.view.choice_dialog import ChoiceDialog from warchron.view.battle_dialog import BattleDialog +from warchron.view.tie_dialog import TieDialog class RoundController: @@ -123,26 +134,47 @@ class RoundController: if not round_id: return rnd = self.app.model.get_round(round_id) - if rnd.is_over: - return + camp = self.app.model.get_campaign_by_round(round_id) + war = self.app.model.get_war_by_round(round_id) + workflow = RoundClosureWorkflow(self.app) 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( + workflow.start(war, camp, rnd) + except DomainError as e: + QMessageBox.warning( self.app.view, - "Tie detected", - "Round has unresolved ties. Resolution system not implemented yet.", + "Deletion forbidden", + str(e), ) - 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 1b4a0e4..1af9b2b 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_id(self, new_id: str) -> None: - self.sector_id = new_id + def set_sector(self, new_sector_id: str) -> None: + self.sector_id = new_sector_id - def set_player_1(self, new_player_id: str | None) -> None: - self.player_1_id = new_player_id + def set_player_1(self, new_camp_part_id: str | None) -> None: + self.player_1_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_player_2(self, new_camp_part_id: str | None) -> None: + self.player_2_id = new_camp_part_id - def set_winner(self, new_player_id: str | None) -> None: - self.winner_id = new_player_id + def set_winner(self, new_camp_part_id: str | None) -> None: + self.winner_id = new_camp_part_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 d7fbc63..5ccea4e 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_participant: str) -> None: - self.war_participant_id = new_participant + def set_war_participant(self, new_war_part_id: str) -> None: + self.war_participant_id = new_war_part_id 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 f92107a..0ed6aee 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_id(self, new_id: str) -> None: - self.participant_id = new_id + def set_participant(self, new_camp_part_id: str) -> None: + self.participant_id = new_camp_part_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 a3234a7..fb31372 100644 --- a/src/warchron/model/closure_service.py +++ b/src/warchron/model/closure_service.py @@ -2,44 +2,69 @@ from __future__ import annotations from typing import List from warchron.constants import ContextType -from warchron.model.tie_manager import ResolutionContext +from warchron.model.exception import ForbiddenOperation +from warchron.model.tie_manager import TieResolver +from warchron.model.war_event import InfluenceGained 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: - @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 [] + # Round methods @staticmethod - def close_campaign(campaign: Campaign) -> List[ResolutionContext]: + def check_round_closable(round: Round) -> None: + if round.is_over: + raise ForbiddenOperation("Round already closed") + 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}", + ) + ) + + @staticmethod + def finalize_round(round: Round) -> None: + round.is_over = True + + # Campaign methods + + @staticmethod + def close_campaign(campaign: Campaign) -> List[str]: if not campaign.all_rounds_finished(): raise RuntimeError("All rounds must be finished to close their campaign") - ties: List[ResolutionContext] = [] + ties: List[str] = [] # for round in campaign.rounds: # # compute score # # if participants have same score @@ -59,11 +84,13 @@ class ClosureService: campaign.is_over = True return [] + # War methods + @staticmethod - def close_war(war: War) -> List[ResolutionContext]: + def close_war(war: War) -> List[str]: if not war.all_campaigns_finished(): raise RuntimeError("All campaigns must be finished to close their war") - ties: List[ResolutionContext] = [] + ties: List[str] = [] # 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 deleted file mode 100644 index db7ee66..0000000 --- a/src/warchron/model/closure_workflow.py +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index 1ed4e4d..0000000 --- a/src/warchron/model/influence_service.py +++ /dev/null @@ -1,23 +0,0 @@ -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 2bfda9b..298139f 100644 --- a/src/warchron/model/score_service.py +++ b/src/warchron/model/score_service.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING +from typing import Dict, 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 84c81b4..da031f9 100644 --- a/src/warchron/model/tie_manager.py +++ b/src/warchron/model/tie_manager.py @@ -1,46 +1,108 @@ +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.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 +from warchron.model.round import Round +from warchron.model.battle import Battle +from warchron.model.war_event import InfluenceSpent, TieResolved class TieResolver: @staticmethod - def resolve( + 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( 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, - ) + 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, ) - # 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 + ) + + @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, + ) -> 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 + return None diff --git a/src/warchron/model/war.py b/src/warchron/model/war.py index e62337a..93e9cc5 100644 --- a/src/warchron/model/war.py +++ b/src/warchron/model/war.py @@ -3,6 +3,7 @@ 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 @@ -25,6 +26,7 @@ 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: @@ -53,6 +55,9 @@ 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: @@ -69,6 +74,7 @@ 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, } @@ -87,6 +93,8 @@ 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 @@ -439,3 +447,18 @@ 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 c4092c0..72c03f3 100644 --- a/src/warchron/model/war_event.py +++ b/src/warchron/model/war_event.py @@ -1,30 +1,143 @@ +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: - def __init__(self, participant_id: str): + TYPE = "WarEvent" + + def __init__(self, participant_id: str | None = None): self.id: str = str(uuid4()) - self.participant_id: str = participant_id + self.participant_id: str | None = 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): - def __init__(self, participant_id: str, context_type: str, context_id: str): + TYPE = "TieResolved" + + def __init__(self, participant_id: str | None, 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): - def __init__(self, participant_id: str, amount: int, context: str): + TYPE = "InfluenceSpent" + + def __init__(self, participant_id: str, amount: int, context_type: str): super().__init__(participant_id) self.amount = amount - self.context = context # "battle_tie", "campaign_tie", etc. + 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) diff --git a/src/warchron/model/war_participant.py b/src/warchron/model/war_participant.py index 78f304e..8708572 100644 --- a/src/warchron/model/war_participant.py +++ b/src/warchron/model/war_participant.py @@ -1,12 +1,6 @@ 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: @@ -14,13 +8,12 @@ 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: str) -> None: - self.player_id = new_player + def set_player(self, new_player_id: str) -> None: + self.player_id = new_player_id def set_faction(self, new_faction: str) -> None: self.faction = new_faction @@ -40,16 +33,3 @@ 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 new file mode 100644 index 0000000..495033c --- /dev/null +++ b/src/warchron/view/tie_dialog.py @@ -0,0 +1,40 @@ +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 7e50a17..c55f4c5 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 result")) + battleDialog.setWindowTitle(_translate("battleDialog", "Battle")) 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 47c1ffb..4fbe3ac 100644 --- a/src/warchron/view/ui/ui_battle_dialog.ui +++ b/src/warchron/view/ui/ui_battle_dialog.ui @@ -14,7 +14,7 @@ - Battle result + Battle diff --git a/src/warchron/view/ui/ui_choice_dialog.py b/src/warchron/view/ui/ui_choice_dialog.py index cffcef6..4c1deaa 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", "Choices")) + choiceDialog.setWindowTitle(_translate("choiceDialog", "Choice")) 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 a6eff58..24142ec 100644 --- a/src/warchron/view/ui/ui_choice_dialog.ui +++ b/src/warchron/view/ui/ui_choice_dialog.ui @@ -14,7 +14,7 @@ - Choices + Choice diff --git a/src/warchron/view/ui/ui_tie_dialog.py b/src/warchron/view/ui/ui_tie_dialog.py new file mode 100644 index 0000000..1bfc512 --- /dev/null +++ b/src/warchron/view/ui/ui_tie_dialog.py @@ -0,0 +1,118 @@ +# 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 new file mode 100644 index 0000000..ccceb54 --- /dev/null +++ b/src/warchron/view/ui/ui_tie_dialog.ui @@ -0,0 +1,196 @@ + + + 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 5950dae..15e1e29 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 +from typing import Callable, List, Dict 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