diff --git a/README.md b/README.md
index 79a292d..6b8c7e7 100644
--- a/README.md
+++ b/README.md
@@ -7,16 +7,12 @@ A simple local app to track players' campaigns for tabletop wargames.
### Main logic
Manage a list of players to sign them up to be selectable for war(s) and campaign(s).
-A "war" year contains several "campaign" events which contain several "battle" games organised in successive rounds.
-Battle results determine campaign score which determines the war score.
-Wars are independent.
-
-### Design notes
-
-* Players are global identities
-* Influence tokens are scoped to a war
-* Campaign order enables historical tie-breakers
-* Effects are generic → future-proof
+A war year offers various objectives along several campaigns. Wars are independent.
+A campaign event presents customisable sectors to fight on during battle rounds. Campaigns are successive and are used for historical tie-breaker.
+A round includes battles to combine all participants according to their choice. Rounds are successive and are used for participants pairing in different priority modes.
+Winning battle grants victory points, narrative points (optional) and influence token (optional).
+Round results determine campaign score, which determines the war score, in different counting modes.
+Victory points determine the winner, narrative points grant scenario award(s) and influence tokens decide tie-breaks.
## Installation
diff --git a/src/warchron/controller/app_controller.py b/src/warchron/controller/app_controller.py
index 7ac6d7c..394d04b 100644
--- a/src/warchron/controller/app_controller.py
+++ b/src/warchron/controller/app_controller.py
@@ -96,6 +96,7 @@ class AppController:
self.navigation.refresh_players_view()
self.navigation.refresh_wars_view()
self.update_window_title()
+ # TODO refresh details view if wars tab selected
def open_file(self) -> None:
if self.is_dirty:
@@ -116,6 +117,7 @@ class AppController:
self.navigation.refresh_players_view()
self.navigation.refresh_wars_view()
self.update_window_title()
+ # TODO refresh details view if wars tab selected
def save(self) -> None:
if not self.current_file:
diff --git a/src/warchron/controller/campaign_controller.py b/src/warchron/controller/campaign_controller.py
index 022fd54..9447058 100644
--- a/src/warchron/controller/campaign_controller.py
+++ b/src/warchron/controller/campaign_controller.py
@@ -1,25 +1,30 @@
-from typing import List, TYPE_CHECKING
+from typing import List, Dict, TYPE_CHECKING
from PyQt6.QtWidgets import QMessageBox, QDialog
-from warchron.constants import RefreshScope
+from warchron.constants import RefreshScope, ContextType, ItemType
if TYPE_CHECKING:
from warchron.controller.app_controller import AppController
from warchron.controller.dtos import (
ParticipantOption,
ObjectiveDTO,
- CampaignParticipantDTO,
SectorDTO,
RoundDTO,
+ CampaignParticipantScoreDTO,
)
+from warchron.model.exception import ForbiddenOperation, DomainError
+from warchron.model.war import War
from warchron.model.campaign import Campaign
from warchron.model.campaign_participant import CampaignParticipant
from warchron.model.sector import Sector
-from warchron.model.closure_service import ClosureService
+from warchron.model.tie_manager import TieContext, TieResolver
+from warchron.model.score_service import ScoreService
from warchron.view.campaign_dialog import CampaignDialog
from warchron.view.campaign_participant_dialog import CampaignParticipantDialog
from warchron.view.sector_dialog import SectorDialog
+from warchron.controller.closure_workflow import CampaignClosureWorkflow
+from warchron.view.tie_dialog import TieDialog
class CampaignController:
@@ -45,17 +50,28 @@ class CampaignController:
for sect in sectors
]
self.app.view.display_campaign_sectors(sectors_for_display)
- camp_parts = camp.get_all_campaign_participants()
- participants_for_display: List[CampaignParticipantDTO] = [
- CampaignParticipantDTO(
- id=p.id,
- player_name=self.app.model.get_participant_name(p.war_participant_id),
- leader=p.leader or "",
- theme=p.theme or "",
+ scores = ScoreService.compute_scores(war, ContextType.CAMPAIGN, campaign_id)
+ rows: List[CampaignParticipantScoreDTO] = []
+ for camp_part in camp.get_all_campaign_participants():
+ war_part_id = camp_part.war_participant_id
+ war_part = war.get_war_participant(war_part_id)
+ player_name = self.app.model.get_player_name(war_part.player_id)
+ score = scores[war_part_id]
+ rows.append(
+ CampaignParticipantScoreDTO(
+ campaign_participant_id=camp_part.id,
+ war_participant_id=war_part_id,
+ player_name=player_name,
+ leader=camp_part.leader or "",
+ theme=camp_part.theme or "",
+ victory_points=score.victory_points,
+ narrative_points=dict(score.narrative_points),
+ )
)
- for p in camp_parts
+ objectives = [
+ ObjectiveDTO(o.id, o.name, o.description) for o in war.get_all_objectives()
]
- self.app.view.display_campaign_participants(participants_for_display)
+ self.app.view.display_campaign_participants(rows, objectives)
self.app.view.endCampaignBtn.setEnabled(not camp.is_over)
def _validate_campaign_inputs(self, name: str, month: int) -> bool:
@@ -89,10 +105,6 @@ class CampaignController:
return self.app.model.add_campaign(
self.app.navigation.selected_war_id, name, month
)
- # self.app.is_dirty = True
- # self.app.navigation.refresh_and_select(
- # RefreshScope.WARS_TREE, item_type=ItemType.CAMPAIGN, item_id=camp.id
- # )
def edit_campaign(self, campaign_id: str) -> None:
camp = self.app.model.get_campaign(campaign_id)
@@ -111,25 +123,46 @@ class CampaignController:
if not campaign_id:
return
camp = self.app.model.get_campaign(campaign_id)
- if camp.is_over:
- return
+ war = self.app.model.get_war_by_campaign(campaign_id)
+ workflow = CampaignClosureWorkflow(self.app)
try:
- ties = ClosureService.close_campaign(camp)
- except RuntimeError as e:
- QMessageBox.warning(self.app.view, "Cannot close campaign", str(e))
- return
- if ties:
- QMessageBox.information(
+ workflow.start(war, camp)
+ except DomainError as e:
+ QMessageBox.warning(
self.app.view,
- "Tie detected",
- "Campaign has unresolved ties.",
+ "Deletion forbidden",
+ str(e),
)
- return
self.app.is_dirty = True
self.app.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS)
- self.app.navigation.refresh(RefreshScope.WARS_TREE)
+ self.app.navigation.refresh_and_select(
+ RefreshScope.WARS_TREE, item_type=ItemType.CAMPAIGN, item_id=campaign_id
+ )
- # Campaign participant methods
+ def resolve_ties(
+ self, war: War, contexts: List[TieContext]
+ ) -> Dict[str, Dict[str, bool]]:
+ bids_map = {}
+ for ctx in contexts:
+ active = TieResolver.get_active_participants(
+ war, ctx.context_type, ctx.context_id, ctx.participants
+ )
+ players = [
+ ParticipantOption(id=pid, name=self.app.model.get_participant_name(pid))
+ for pid in active
+ ]
+ counters = [war.get_influence_tokens(pid) for pid in active]
+ dialog = TieDialog(
+ parent=self.app.view,
+ players=players,
+ counters=counters,
+ context_type=ContextType.CAMPAIGN,
+ 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
def create_campaign_participant(self) -> CampaignParticipant | None:
if not self.app.navigation.selected_campaign_id:
@@ -189,7 +222,7 @@ class CampaignController:
self.app.view, "Invalid name", "Sector name cannot be empty."
)
return False
- # allow same objectives in different fields?
+ # TODO allow same objectives in different fields?
return True
def create_sector(self) -> Sector | None:
diff --git a/src/warchron/controller/closure_workflow.py b/src/warchron/controller/closure_workflow.py
index 71a1e3d..01076d2 100644
--- a/src/warchron/controller/closure_workflow.py
+++ b/src/warchron/controller/closure_workflow.py
@@ -3,16 +3,11 @@ 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:
@@ -25,53 +20,33 @@ 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)
+ ties = TieResolver.find_battle_ties(war, round.id)
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,
+ bids_map = self.app.rounds.resolve_ties(war, ties)
+ for tie in ties:
+ bids = bids_map[tie.context_id]
+ TieResolver.apply_bids(war, tie.context_type, tie.context_id, bids)
+ TieResolver.resolve_tie_state(
+ war, tie.context_type, tie.context_id, tie.participants, bids
)
- TieResolver.try_tie_break(
- war,
- ctx.context_type,
- ctx.context_id,
- ctx.participants,
- )
- ties = TieResolver.find_round_ties(round, war)
+ ties = TieResolver.find_battle_ties(war, round.id)
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],
- )
+
+class CampaignClosureWorkflow(ClosureWorkflow):
+
+ def start(self, war: War, campaign: Campaign) -> None:
+ ClosureService.check_campaign_closable(campaign)
+ ties = TieResolver.find_campaign_ties(war, campaign.id)
+ while ties:
+ bids_map = self.app.campaigns.resolve_ties(war, ties)
+ for tie in ties:
+ bids = bids_map[tie.context_id]
+ TieResolver.apply_bids(war, tie.context_type, tie.context_id, bids)
+ TieResolver.resolve_tie_state(
+ war, tie.context_type, tie.context_id, tie.participants, bids
+ )
+ ties = TieResolver.find_campaign_ties(war, campaign.id)
+ ClosureService.finalize_campaign(campaign)
diff --git a/src/warchron/controller/dtos.py b/src/warchron/controller/dtos.py
index 5222b03..7d52aaf 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:
@@ -109,8 +107,20 @@ class BattleDTO:
player2_tooltip: str | None = None
-@dataclass
-class TieContext:
- context_type: ContextType
- context_id: str
- participants: List[str] # war_participant_ids
+@dataclass(frozen=True, slots=True)
+class ParticipantScoreDTO:
+ participant_id: str
+ player_name: str
+ victory_points: int
+ narrative_points: dict[str, int]
+
+
+@dataclass(frozen=True, slots=True)
+class CampaignParticipantScoreDTO:
+ campaign_participant_id: str
+ war_participant_id: str
+ player_name: str
+ leader: str
+ theme: str
+ victory_points: int
+ narrative_points: dict[str, int]
diff --git a/src/warchron/controller/round_controller.py b/src/warchron/controller/round_controller.py
index d0c8601..8fdc4c9 100644
--- a/src/warchron/controller/round_controller.py
+++ b/src/warchron/controller/round_controller.py
@@ -6,7 +6,8 @@ from PyQt6.QtGui import QIcon
from warchron.constants import ItemType, RefreshScope, Icons, IconName, ContextType
from warchron.model.exception import ForbiddenOperation, DomainError
-from warchron.model.tie_manager import TieResolver
+from warchron.model.tie_manager import TieResolver, TieContext
+from warchron.model.result_checker import ResultChecker
from warchron.model.round import Round
from warchron.model.war import War
@@ -18,7 +19,6 @@ from warchron.controller.dtos import (
SectorDTO,
ChoiceDTO,
BattleDTO,
- TieContext,
)
from warchron.controller.closure_workflow import RoundClosureWorkflow
from warchron.view.choice_dialog import ChoiceDialog
@@ -108,7 +108,7 @@ class RoundController:
if TieResolver.was_tie_broken_by_tokens(
war, ContextType.BATTLE, battle.sector_id
):
- effective_winner = TieResolver.get_effective_winner_id(
+ effective_winner = ResultChecker.get_effective_winner_id(
war, ContextType.BATTLE, battle.sector_id, None
)
p1_war = None
diff --git a/src/warchron/model/battle.py b/src/warchron/model/battle.py
index 1af9b2b..3ff23a7 100644
--- a/src/warchron/model/battle.py
+++ b/src/warchron/model/battle.py
@@ -43,12 +43,13 @@ class Battle:
def is_draw(self) -> bool:
if self.winner_id is not None:
return False
- # Case 1: score entered → interpreted as unresolved outcome
if self.score and self.score.strip():
return True
- # Case 2: explicit draw mention
if self.victory_condition:
- if "draw" in self.victory_condition.casefold():
+ if any(
+ keyword in (self.victory_condition or "").casefold()
+ for keyword in ["draw", "tie", "square"]
+ ):
return True
return False
diff --git a/src/warchron/model/campaign.py b/src/warchron/model/campaign.py
index 2b8148a..eb55bd3 100644
--- a/src/warchron/model/campaign.py
+++ b/src/warchron/model/campaign.py
@@ -187,6 +187,7 @@ class Campaign:
mission: str | None,
description: str | None,
) -> None:
+ # TODO raise error if sector used in a closed round (potential tokens)
if self.is_over:
raise ForbiddenOperation("Can't update sector in a closed campaign.")
sect = self.get_sector(sector_id)
@@ -284,6 +285,22 @@ class Campaign:
return rnd
raise KeyError(f"Round {round_id} not found")
+ def get_round_index(self, round_id: str | None) -> int | None:
+ if round_id is None:
+ return None
+ for index, rnd in enumerate(self.rounds, start=1):
+ if rnd.id == round_id:
+ return index
+ raise KeyError("Round not found in campaign")
+
+ # TODO replace multiloops by internal has_* method
+ def get_round_by_battle(self, sector_id: str) -> Round:
+ for rnd in self.rounds:
+ for bat in rnd.battles.values():
+ if bat.sector_id == sector_id:
+ return rnd
+ raise KeyError(f"Battle {sector_id} not found in any Round")
+
def get_all_rounds(self) -> List[Round]:
return list(self.rounds)
@@ -309,14 +326,6 @@ class Campaign:
if rnd:
self.rounds.remove(rnd)
- def get_round_index(self, round_id: str | None) -> int | None:
- if round_id is None:
- return None
- for index, rnd in enumerate(self.rounds, start=1):
- if rnd.id == round_id:
- return index
- raise KeyError("Round not found in campaign")
-
# Choice methods
def create_choice(self, round_id: str, participant_id: str) -> Choice:
diff --git a/src/warchron/model/closure_service.py b/src/warchron/model/closure_service.py
index fb31372..3816e5c 100644
--- a/src/warchron/model/closure_service.py
+++ b/src/warchron/model/closure_service.py
@@ -3,7 +3,7 @@ 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.result_checker import ResultChecker
from warchron.model.war_event import InfluenceGained
from warchron.model.war import War
from warchron.model.campaign import Campaign
@@ -27,16 +27,16 @@ class ClosureService:
@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}"
+ isinstance(e, InfluenceGained)
+ and e.context_id == f"battle:{battle.sector_id}"
for e in war.events
)
if already_granted:
return
+ base_winner = None
if battle.winner_id is not None:
base_winner = campaign.participants[battle.winner_id].war_participant_id
- else:
- base_winner = None
- effective_winner = TieResolver.get_effective_winner_id(
+ effective_winner = ResultChecker.get_effective_winner_id(
war,
ContextType.BATTLE,
battle.sector_id,
@@ -50,7 +50,8 @@ class ClosureService:
InfluenceGained(
participant_id=effective_winner,
amount=1,
- source=f"battle:{battle.sector_id}",
+ context_type=ContextType.BATTLE,
+ context_id=f"battle:{battle.sector_id}",
)
)
@@ -61,28 +62,17 @@ class ClosureService:
# Campaign methods
@staticmethod
- def close_campaign(campaign: Campaign) -> List[str]:
+ def check_campaign_closable(campaign: Campaign) -> None:
+ if campaign.is_over:
+ raise ForbiddenOperation("Campaign already closed")
if not campaign.all_rounds_finished():
- raise RuntimeError("All rounds must be finished to close their campaign")
- ties: List[str] = []
- # for round in campaign.rounds:
- # # compute score
- # # if participants have same score
- # ties.append(
- # ResolutionContext(
- # context_type=ContextType.CAMPAIGN,
- # context_id=campaign.id,
- # participant_ids=[
- # # TODO ref to War.participants at some point
- # campaign.participants[campaign_participant_id],
- # campaign.participants[campaign_participant_id],
- # ],
- # )
- # )
- if ties:
- return ties
+ raise ForbiddenOperation(
+ "All rounds must be closed to close their campaign"
+ )
+
+ @staticmethod
+ def finalize_campaign(campaign: Campaign) -> None:
campaign.is_over = True
- return []
# War methods
diff --git a/src/warchron/model/result_checker.py b/src/warchron/model/result_checker.py
new file mode 100644
index 0000000..de07440
--- /dev/null
+++ b/src/warchron/model/result_checker.py
@@ -0,0 +1,24 @@
+from warchron.constants import ContextType
+from warchron.model.war import War
+from warchron.model.war_event import TieResolved
+
+
+class ResultChecker:
+ @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/score_service.py b/src/warchron/model/score_service.py
index 298139f..16da0ab 100644
--- a/src/warchron/model/score_service.py
+++ b/src/warchron/model/score_service.py
@@ -1,45 +1,77 @@
-from typing import Dict, TYPE_CHECKING
+from typing import Dict, Iterator
+from dataclasses import dataclass, field
-if TYPE_CHECKING:
- from warchron.model.war import War
+from warchron.model.result_checker import ResultChecker
+from warchron.constants import ContextType
+from warchron.model.war import War
+from warchron.model.battle import Battle
+
+
+@dataclass(slots=True)
+class ParticipantScore:
+ victory_points: int = 0
+ narrative_points: Dict[str, int] = field(default_factory=dict)
class ScoreService:
@staticmethod
- def compute_victory_points_for_participant(war: "War", participant_id: str) -> int:
- total = 0
- for campaign in war.campaigns:
- for round_ in campaign.rounds:
- for battle in round_.battles.values():
- if battle.winner_id == participant_id:
- sector = campaign.sectors[battle.sector_id]
- if sector.major_objective_id:
- total += war.major_value
- if sector.minor_objective_id:
- total += war.minor_value
- return total
+ def _get_battles_for_context(
+ war: War, context_type: ContextType, context_id: str
+ ) -> Iterator[Battle]:
+ if context_type == ContextType.WAR:
+ for camp in war.campaigns:
+ for rnd in camp.rounds:
+ if not rnd.is_over:
+ continue
+ yield from rnd.battles.values()
+
+ elif context_type == ContextType.CAMPAIGN:
+ campaign = war.get_campaign(context_id)
+ for rnd in campaign.rounds:
+ if not rnd.is_over:
+ continue
+ yield from rnd.battles.values()
+
+ elif context_type == ContextType.BATTLE:
+ battle = war.get_battle(context_id)
+ campaign = war.get_campaign_by_sector(battle.sector_id)
+ rnd = campaign.get_round_by_battle(context_id)
+ if rnd and rnd.is_over:
+ yield battle
@staticmethod
- def compute_narrative_points_for_participant(
- war: "War", participant_id: str
- ) -> Dict[str, int]:
- totals: Dict[str, int] = {}
- for obj_id in war.objectives:
- totals[obj_id] = 0
- for campaign in war.campaigns:
- for round_ in campaign.rounds:
- for battle in round_.battles.values():
- if battle.winner_id == participant_id:
- sector = campaign.sectors[battle.sector_id]
- if sector.major_objective_id:
- totals[sector.major_objective_id] += war.major_value
- if sector.minor_objective_id:
- totals[sector.minor_objective_id] += war.minor_value
- return totals
-
- # def compute_round_results(round)
-
- # def compute_campaign_winner(campaign)
-
- # def compute_war_winner(war)
+ def compute_scores(
+ war: War, context_type: ContextType, context_id: str
+ ) -> Dict[str, ParticipantScore]:
+ scores = {
+ pid: ParticipantScore(
+ narrative_points={obj_id: 0 for obj_id in war.objectives}
+ )
+ for pid in war.participants
+ }
+ battles = ScoreService._get_battles_for_context(war, context_type, context_id)
+ for battle in battles:
+ base_winner = None
+ if battle.winner_id is not None:
+ campaign = war.get_campaign_by_campaign_participant(battle.winner_id)
+ if campaign is not None:
+ base_winner = campaign.participants[
+ battle.winner_id
+ ].war_participant_id
+ winner = ResultChecker.get_effective_winner_id(
+ war, ContextType.BATTLE, battle.sector_id, base_winner
+ )
+ if winner is None:
+ continue
+ scores[winner].victory_points += 1
+ sector = war.get_sector(battle.sector_id)
+ if sector.major_objective_id:
+ scores[winner].narrative_points[
+ sector.major_objective_id
+ ] += war.major_value
+ if sector.minor_objective_id:
+ scores[winner].narrative_points[
+ sector.minor_objective_id
+ ] += war.minor_value
+ return scores
diff --git a/src/warchron/model/tie_manager.py b/src/warchron/model/tie_manager.py
index 54cef74..fd678d7 100644
--- a/src/warchron/model/tie_manager.py
+++ b/src/warchron/model/tie_manager.py
@@ -1,31 +1,89 @@
-from typing import List, Dict
+from typing import List, Dict, DefaultDict
+from dataclasses import dataclass
+from collections import defaultdict
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.score_service import ScoreService
+
+
+@dataclass
+class TieContext:
+ context_type: ContextType
+ context_id: str
+ participants: List[str] # war_participant_ids
class TieResolver:
@staticmethod
- def find_round_ties(round: Round, war: War) -> List[Battle]:
+ def find_battle_ties(war: War, round_id: str) -> List[TieContext]:
+ round = war.get_round(round_id)
+ campaign = war.get_campaign_by_round(round_id)
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 TieResolver.is_tie_resolved(war, ContextType.BATTLE, battle.sector_id):
+ continue
+
+ if campaign is None:
+ raise RuntimeError("No campaign for this battle tie")
+ if battle.player_1_id is None or battle.player_2_id is None:
+ raise RuntimeError("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
+ if not TieResolver.can_tie_be_resolved(
+ war, ContextType.BATTLE, battle.sector_id, [p1, p2]
+ ):
+ war.events.append(
+ TieResolved(None, ContextType.BATTLE, battle.sector_id)
+ )
+ continue
+ ties.append(
+ TieContext(
+ context_type=ContextType.BATTLE,
+ context_id=battle.sector_id,
+ participants=[p1, p2],
+ )
)
- if not resolved:
- ties.append(battle)
return ties
+ @staticmethod
+ def find_campaign_ties(war: War, campaign_id: str) -> List[TieContext]:
+ if TieResolver.is_tie_resolved(war, ContextType.CAMPAIGN, campaign_id):
+ return []
+ scores = ScoreService.compute_scores(war, ContextType.CAMPAIGN, campaign_id)
+ buckets: DefaultDict[int, List[str]] = defaultdict(list)
+ for pid, score in scores.items():
+ buckets[score.victory_points].append(pid)
+ ties: List[TieContext] = []
+ for score_value, participants in buckets.items():
+ if len(participants) <= 1:
+ continue
+ tie_id = f"{campaign_id}:score:{score_value}"
+ if TieResolver.is_tie_resolved(war, ContextType.CAMPAIGN, tie_id):
+ continue
+ if not TieResolver.can_tie_be_resolved(
+ war, ContextType.CAMPAIGN, tie_id, participants
+ ):
+ war.events.append(TieResolved(None, ContextType.CAMPAIGN, tie_id))
+ continue
+ ties.append(
+ TieContext(
+ context_type=ContextType.CAMPAIGN,
+ context_id=tie_id,
+ participants=participants,
+ )
+ )
+ return ties
+
+ @staticmethod
+ def find_war_ties(war: War) -> List[TieContext]:
+ return [] # TODO
+
@staticmethod
def apply_bids(
war: War,
@@ -43,69 +101,80 @@ class TieResolver:
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(
+ def rank_by_tokens(
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):
+ participants: List[str],
+ ) -> List[List[str]]:
+ spent = {pid: 0 for pid in participants}
+ for ev in war.events:
if (
- isinstance(ev, TieResolved)
+ isinstance(ev, InfluenceSpent)
and ev.context_type == context_type
and ev.context_id == context_id
+ and ev.participant_id in spent
):
- return ev.participant_id # None if confirmed draw
+ spent[ev.participant_id] += ev.amount
+ sorted_items = sorted(spent.items(), key=lambda x: x[1], reverse=True)
+ groups: List[List[str]] = []
+ current_score = None
+ for pid, score in sorted_items:
+ if score != current_score:
+ groups.append([])
+ current_score = score
+ groups[-1].append(pid)
+ return groups
- return None
+ @staticmethod
+ def get_active_participants(
+ war: War,
+ context_type: ContextType,
+ context_id: str,
+ participants: List[str],
+ ) -> List[str]:
+ groups = TieResolver.rank_by_tokens(war, context_type, context_id, participants)
+ return groups[0]
+
+ @staticmethod
+ def resolve_tie_state(
+ war: War,
+ context_type: ContextType,
+ context_id: str,
+ participants: List[str],
+ bids: dict[str, bool] | None = None,
+ ) -> None:
+ active = TieResolver.get_active_participants(
+ war, context_type, context_id, participants
+ )
+ # confirmed draw if non had bid
+ if not active:
+ war.events.append(TieResolved(None, context_type, context_id))
+ return
+ # confirmed draw if current bids are 0
+ if bids is not None and not any(bids.values()):
+ war.events.append(TieResolved(None, context_type, context_id))
+ return
+ # else rank_by_tokens
+ groups = TieResolver.rank_by_tokens(war, context_type, context_id, participants)
+ if len(groups[0]) == 1:
+ war.events.append(TieResolved(groups[0][0], context_type, context_id))
+ return
+ # if tie persists, do nothing, workflow will call again
+
+ @staticmethod
+ def can_tie_be_resolved(
+ war: War, context_type: ContextType, context_id: str, participants: List[str]
+ ) -> bool:
+ active = TieResolver.get_active_participants(
+ war, context_type, context_id, participants
+ )
+ return any(war.get_influence_tokens(pid) > 0 for pid in active)
@staticmethod
def was_tie_broken_by_tokens(
@@ -121,3 +190,12 @@ class TieResolver:
):
return ev.participant_id is not None
return False
+
+ @staticmethod
+ def is_tie_resolved(war: War, context_type: ContextType, context_id: str) -> bool:
+ return any(
+ isinstance(ev, TieResolved)
+ and ev.context_type == context_type
+ and ev.context_id == context_id
+ for ev in war.events
+ )
diff --git a/src/warchron/model/war.py b/src/warchron/model/war.py
index 93e9cc5..8681d12 100644
--- a/src/warchron/model/war.py
+++ b/src/warchron/model/war.py
@@ -371,6 +371,14 @@ class War:
# Round methods
+ # TODO replace multiloops by internal has_* method
+ def get_round(self, round_id: str) -> Round:
+ for camp in self.campaigns:
+ for rnd in camp.rounds:
+ if rnd.id == round_id:
+ return rnd
+ raise KeyError("Round not found")
+
def add_round(self, campaign_id: str) -> Round:
camp = self.get_campaign(campaign_id)
return camp.add_round()
@@ -413,6 +421,15 @@ class War:
# Battle methods
+ # TODO replace multiloops by internal has_* method
+ def get_battle(self, battle_id: str) -> Battle:
+ for camp in self.campaigns:
+ for rnd in camp.rounds:
+ for bat in rnd.battles.values():
+ if bat.sector_id == battle_id:
+ return bat
+ raise KeyError("Battle not found")
+
def create_battle(self, round_id: str, sector_id: str) -> Battle:
camp = self.get_campaign_by_round(round_id)
if camp is not None:
diff --git a/src/warchron/model/war_event.py b/src/warchron/model/war_event.py
index 72c03f3..62209cd 100644
--- a/src/warchron/model/war_event.py
+++ b/src/warchron/model/war_event.py
@@ -15,9 +15,11 @@ def register_event(cls: Type[T]) -> Type[T]:
class WarEvent:
TYPE = "WarEvent"
- def __init__(self, participant_id: str | None = None):
+ def __init__(self, participant_id: str | None, context_type: str, context_id: str):
self.id: str = str(uuid4())
self.participant_id: str | None = participant_id
+ self.context_type = context_type # battle, round, campaign, war
+ self.context_id = context_id
self.timestamp: datetime = datetime.now()
def set_id(self, new_id: str) -> None:
@@ -34,6 +36,8 @@ class WarEvent:
"type": self.TYPE,
"id": self.id,
"participant_id": self.participant_id,
+ "context_type": self.context_type,
+ "context_id": self.context_id,
"timestamp": self.timestamp.isoformat(),
}
@@ -41,6 +45,8 @@ class WarEvent:
def _base_fromDict(cls: Type[T], ev: T, data: Dict[str, Any]) -> T:
ev.id = data["id"]
ev.participant_id = data["participant_id"]
+ ev.context_type = data["context_type"]
+ ev.context_id = data["context_id"]
ev.timestamp = datetime.fromisoformat(data["timestamp"])
return ev
@@ -58,21 +64,10 @@ class TieResolved(WarEvent):
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
+ super().__init__(participant_id, context_type, 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
@@ -89,17 +84,17 @@ class TieResolved(WarEvent):
class InfluenceGained(WarEvent):
TYPE = "InfluenceGained"
- def __init__(self, participant_id: str, amount: int, source: str):
- super().__init__(participant_id)
+ def __init__(
+ self, participant_id: str, amount: int, context_type: str, context_id: str
+ ):
+ super().__init__(participant_id, context_type, context_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
@@ -108,8 +103,9 @@ class InfluenceGained(WarEvent):
def fromDict(cls, data: Dict[str, Any]) -> InfluenceGained:
ev = cls(
data["participant_id"],
- data["amount"],
- data["source"],
+ int(data["amount"]),
+ data["context_type"],
+ data["context_id"],
)
return cls._base_fromDict(ev, data)
@@ -118,17 +114,17 @@ class InfluenceGained(WarEvent):
class InfluenceSpent(WarEvent):
TYPE = "InfluenceSpent"
- def __init__(self, participant_id: str, amount: int, context_type: str):
- super().__init__(participant_id)
+ def __init__(
+ self, participant_id: str, amount: int, context_type: str, context_id: str
+ ):
+ super().__init__(participant_id, context_type, context_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
@@ -137,7 +133,8 @@ class InfluenceSpent(WarEvent):
def fromDict(cls, data: Dict[str, Any]) -> InfluenceSpent:
ev = cls(
data["participant_id"],
- data["amount"],
+ int(data["amount"]),
data["context_type"],
+ data["context_id"],
)
return cls._base_fromDict(ev, data)
diff --git a/src/warchron/view/tie_dialog.py b/src/warchron/view/tie_dialog.py
index 201ea32..2e75e48 100644
--- a/src/warchron/view/tie_dialog.py
+++ b/src/warchron/view/tie_dialog.py
@@ -1,6 +1,15 @@
from typing import List, Dict
-from PyQt6.QtWidgets import QWidget, QDialog
+from PyQt6.QtWidgets import (
+ QWidget,
+ QDialog,
+ QCheckBox,
+ QGroupBox,
+ QHBoxLayout,
+ QVBoxLayout,
+ QLabel,
+ QLineEdit,
+)
from PyQt6.QtCore import Qt
from warchron.constants import Icons, IconName, ContextType, RESOURCES_DIR
@@ -20,32 +29,47 @@ class TieDialog(QDialog):
) -> None:
super().__init__(parent)
self._context_id = context_id
- self._p1_id = players[0].id
- self._p2_id = players[1].id
+ self._checkboxes: Dict[str, QCheckBox] = {}
self.ui: Ui_tieDialog = Ui_tieDialog()
self.ui.setupUi(self) # type: ignore
- self.ui.tieContext.setText(self._get_context_title(context_type))
- icon_path = (RESOURCES_DIR / Icons._paths[IconName.TOKENS]).as_posix()
- html = f'
Remaining token(s)'
- self.ui.label_2.setText(html)
- self.ui.label_2.setTextFormat(Qt.TextFormat.RichText)
- self.ui.label_3.setText(html)
- self.ui.label_3.setTextFormat(Qt.TextFormat.RichText)
- 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))
+ self.ui.tieContext.setText(self._get_context_title(context_type))
+ grid = self.ui.playersGridLayout
+ icon_path = (RESOURCES_DIR / Icons._paths[IconName.TOKENS]).as_posix()
+ token_html = (
+ f'
Remaining token(s)'
+ )
+ for i, (player, tokens) in enumerate(zip(players, counters)):
+ group = QGroupBox(player.name)
+ row_layout = QHBoxLayout()
+ left = QVBoxLayout()
+ spend_label = QLabel("Spend token")
+ token_label = QLabel(token_html)
+ token_label.setTextFormat(Qt.TextFormat.RichText)
+ left.addWidget(spend_label)
+ left.addWidget(token_label)
+ right = QVBoxLayout()
+ checkbox = QCheckBox()
+ count = QLineEdit(str(tokens))
+ count.setEnabled(False)
+ if tokens < 1:
+ checkbox.setDisabled(True)
+ right.addWidget(checkbox)
+ right.addWidget(count)
+ row_layout.addLayout(left)
+ row_layout.addLayout(right)
+ group.setLayout(row_layout)
+ row = i // 2
+ col = i % 2
+ grid.addWidget(group, row, col)
+ self._checkboxes[player.id] = checkbox
+ grid.setColumnStretch(0, 1)
+ grid.setColumnStretch(1, 1)
+ self.ui.playersScrollArea.setMinimumHeight(110)
+ self.adjustSize()
def get_bids(self) -> Dict[str, bool]:
- return {
- self._p1_id: self.ui.tokenSpend_1.isChecked(),
- self._p2_id: self.ui.tokenSpend_2.isChecked(),
- }
+ return {pid: checkbox.isChecked() for pid, checkbox in self._checkboxes.items()}
@staticmethod
def _get_context_title(context_type: ContextType) -> str:
diff --git a/src/warchron/view/ui/ui_tie_dialog.py b/src/warchron/view/ui/ui_tie_dialog.py
index 1bfc512..0361373 100644
--- a/src/warchron/view/ui/ui_tie_dialog.py
+++ b/src/warchron/view/ui/ui_tie_dialog.py
@@ -13,12 +13,16 @@ class Ui_tieDialog(object):
def setupUi(self, tieDialog):
tieDialog.setObjectName("tieDialog")
tieDialog.setWindowModality(QtCore.Qt.WindowModality.ApplicationModal)
- tieDialog.resize(477, 174)
+ tieDialog.resize(481, 155)
icon = QtGui.QIcon()
- icon.addPixmap(QtGui.QPixmap(".\\src\\warchron\\view\\ui\\../resources/warchron_logo.png"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
+ 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.gridLayout = QtWidgets.QGridLayout(tieDialog)
+ self.gridLayout.setObjectName("gridLayout")
self.horizontalLayout_3 = QtWidgets.QHBoxLayout()
self.horizontalLayout_3.setObjectName("horizontalLayout_3")
self.tieContext = QtWidgets.QLabel(parent=tieDialog)
@@ -29,87 +33,47 @@ class Ui_tieDialog(object):
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)
+ 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.gridLayout.addLayout(self.horizontalLayout_3, 0, 0, 1, 1)
+ self.playersScrollArea = QtWidgets.QScrollArea(parent=tieDialog)
+ self.playersScrollArea.setWidgetResizable(True)
+ self.playersScrollArea.setObjectName("playersScrollArea")
+ self.scrollAreaWidgetContents = QtWidgets.QWidget()
+ self.scrollAreaWidgetContents.setGeometry(QtCore.QRect(0, 0, 461, 78))
+ self.scrollAreaWidgetContents.setObjectName("scrollAreaWidgetContents")
+ self.playersGridLayout = QtWidgets.QGridLayout(self.scrollAreaWidgetContents)
+ self.playersGridLayout.setObjectName("playersGridLayout")
+ self.playersScrollArea.setWidget(self.scrollAreaWidgetContents)
+ self.gridLayout.addWidget(self.playersScrollArea, 1, 0, 1, 1)
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.setStandardButtons(
+ QtWidgets.QDialogButtonBox.StandardButton.Cancel
+ | QtWidgets.QDialogButtonBox.StandardButton.Ok
+ )
self.buttonBox.setObjectName("buttonBox")
- self.verticalLayout_5.addWidget(self.buttonBox)
+ self.gridLayout.addWidget(self.buttonBox, 2, 0, 1, 1)
self.retranslateUi(tieDialog)
- self.buttonBox.accepted.connect(tieDialog.accept) # type: ignore
- self.buttonBox.rejected.connect(tieDialog.reject) # type: ignore
+ 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()
diff --git a/src/warchron/view/ui/ui_tie_dialog.ui b/src/warchron/view/ui/ui_tie_dialog.ui
index ccceb54..38e6744 100644
--- a/src/warchron/view/ui/ui_tie_dialog.ui
+++ b/src/warchron/view/ui/ui_tie_dialog.ui
@@ -9,8 +9,8 @@
0
0
- 477
- 174
+ 481
+ 155
@@ -20,8 +20,8 @@
../resources/warchron_logo.png../resources/warchron_logo.png
-
- -
+
+
-
-
@@ -52,101 +52,25 @@
- -
-
-
-
-
-
- Player 1
-
-
-
-
-
-
-
-
-
- Spend token
-
-
-
- -
-
-
- Remaining token(s)
-
-
-
-
-
- -
-
-
-
-
-
-
-
-
-
- -
-
-
- false
-
-
-
-
-
-
-
-
- -
-
-
- Player 2
-
-
-
-
-
-
-
-
-
- Spend token
-
-
-
- -
-
-
- Remaining token(s)
-
-
-
-
-
- -
-
-
-
-
-
-
-
-
-
- -
-
-
- false
-
-
-
-
-
-
-
-
-
+ -
+
+
+ true
+
+
+
+
+ 0
+ 0
+ 461
+ 78
+
+
+
+
+
- -
+
-
Qt::Horizontal
diff --git a/src/warchron/view/view.py b/src/warchron/view/view.py
index 5082df5..aae2527 100644
--- a/src/warchron/view/view.py
+++ b/src/warchron/view/view.py
@@ -15,10 +15,10 @@ from warchron.controller.dtos import (
WarDTO,
WarParticipantDTO,
ObjectiveDTO,
- CampaignParticipantDTO,
SectorDTO,
ChoiceDTO,
BattleDTO,
+ CampaignParticipantScoreDTO,
)
from warchron.view.helpers import (
format_campaign_label,
@@ -464,19 +464,33 @@ class View(QtWidgets.QMainWindow, Ui_MainWindow):
table.resizeColumnsToContents()
def display_campaign_participants(
- self, participants: List[CampaignParticipantDTO]
+ self,
+ participants: List[CampaignParticipantScoreDTO],
+ objectives: List[ObjectiveDTO],
) -> None:
table = self.campaignParticipantsTable
table.clearContents()
+ base_cols = ["Player", "Leader", "Theme", "Victory"]
+ headers = base_cols + [obj.name for obj in objectives]
+ table.setColumnCount(len(headers))
+ table.setHorizontalHeaderLabels(headers)
table.setRowCount(len(participants))
for row, part in enumerate(participants):
name_item = QtWidgets.QTableWidgetItem(part.player_name)
lead_item = QtWidgets.QTableWidgetItem(part.leader)
theme_item = QtWidgets.QTableWidgetItem(part.theme)
- name_item.setData(Qt.ItemDataRole.UserRole, part.id)
+ VP_item = QtWidgets.QTableWidgetItem(str(part.victory_points))
+ name_item.setData(Qt.ItemDataRole.UserRole, part.campaign_participant_id)
table.setItem(row, 0, name_item)
table.setItem(row, 1, lead_item)
table.setItem(row, 2, theme_item)
+ table.setItem(row, 3, VP_item)
+ col = 4
+ for obj in objectives:
+ value = part.narrative_points.get(obj.id, 0)
+ NP_item = QtWidgets.QTableWidgetItem(str(value))
+ table.setItem(row, col, NP_item)
+ col += 1
table.resizeColumnsToContents()
# Round page