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