Compare commits

..

5 commits

Author SHA1 Message Date
Maxime Réaux
60992c22df fix tie-break loser re-bid 2026-02-23 13:16:45 +01:00
Maxime Réaux
c9407f9407 fix campaign tie loop + dynamic tie dialog 2026-02-23 11:37:50 +01:00
Maxime Réaux
f339498f97 fix round tie loop + improve tie ranking 2026-02-20 23:44:22 +01:00
Maxime Réaux
60d8e6ca15 detect campaign tie 2026-02-20 11:01:25 +01:00
Maxime Réaux
7c9c941864 compute campaign points from rounds 2026-02-19 14:17:42 +01:00
18 changed files with 541 additions and 451 deletions

View file

@ -7,16 +7,12 @@ A simple local app to track players' campaigns for tabletop wargames.
### Main logic ### Main logic
Manage a list of players to sign them up to be selectable for war(s) and campaign(s). 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. A war year offers various objectives along several campaigns. Wars are independent.
Battle results determine campaign score which determines the war score. A campaign event presents customisable sectors to fight on during battle rounds. Campaigns are successive and are used for historical tie-breaker.
Wars are independent. 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).
### Design notes 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.
* Players are global identities
* Influence tokens are scoped to a war
* Campaign order enables historical tie-breakers
* Effects are generic → future-proof
## Installation ## Installation

View file

@ -96,6 +96,7 @@ class AppController:
self.navigation.refresh_players_view() self.navigation.refresh_players_view()
self.navigation.refresh_wars_view() self.navigation.refresh_wars_view()
self.update_window_title() self.update_window_title()
# TODO refresh details view if wars tab selected
def open_file(self) -> None: def open_file(self) -> None:
if self.is_dirty: if self.is_dirty:
@ -116,6 +117,7 @@ class AppController:
self.navigation.refresh_players_view() self.navigation.refresh_players_view()
self.navigation.refresh_wars_view() self.navigation.refresh_wars_view()
self.update_window_title() self.update_window_title()
# TODO refresh details view if wars tab selected
def save(self) -> None: def save(self) -> None:
if not self.current_file: if not self.current_file:

View file

@ -1,25 +1,30 @@
from typing import List, TYPE_CHECKING from typing import List, Dict, TYPE_CHECKING
from PyQt6.QtWidgets import QMessageBox, QDialog from PyQt6.QtWidgets import QMessageBox, QDialog
from warchron.constants import RefreshScope from warchron.constants import RefreshScope, ContextType, ItemType
if TYPE_CHECKING: if TYPE_CHECKING:
from warchron.controller.app_controller import AppController from warchron.controller.app_controller import AppController
from warchron.controller.dtos import ( from warchron.controller.dtos import (
ParticipantOption, ParticipantOption,
ObjectiveDTO, ObjectiveDTO,
CampaignParticipantDTO,
SectorDTO, SectorDTO,
RoundDTO, 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 import Campaign
from warchron.model.campaign_participant import CampaignParticipant from warchron.model.campaign_participant import CampaignParticipant
from warchron.model.sector import Sector 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_dialog import CampaignDialog
from warchron.view.campaign_participant_dialog import CampaignParticipantDialog from warchron.view.campaign_participant_dialog import CampaignParticipantDialog
from warchron.view.sector_dialog import SectorDialog from warchron.view.sector_dialog import SectorDialog
from warchron.controller.closure_workflow import CampaignClosureWorkflow
from warchron.view.tie_dialog import TieDialog
class CampaignController: class CampaignController:
@ -45,17 +50,28 @@ class CampaignController:
for sect in sectors for sect in sectors
] ]
self.app.view.display_campaign_sectors(sectors_for_display) self.app.view.display_campaign_sectors(sectors_for_display)
camp_parts = camp.get_all_campaign_participants() scores = ScoreService.compute_scores(war, ContextType.CAMPAIGN, campaign_id)
participants_for_display: List[CampaignParticipantDTO] = [ rows: List[CampaignParticipantScoreDTO] = []
CampaignParticipantDTO( for camp_part in camp.get_all_campaign_participants():
id=p.id, war_part_id = camp_part.war_participant_id
player_name=self.app.model.get_participant_name(p.war_participant_id), war_part = war.get_war_participant(war_part_id)
leader=p.leader or "", player_name = self.app.model.get_player_name(war_part.player_id)
theme=p.theme or "", 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) self.app.view.endCampaignBtn.setEnabled(not camp.is_over)
def _validate_campaign_inputs(self, name: str, month: int) -> bool: def _validate_campaign_inputs(self, name: str, month: int) -> bool:
@ -89,10 +105,6 @@ class CampaignController:
return self.app.model.add_campaign( return self.app.model.add_campaign(
self.app.navigation.selected_war_id, name, month 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: def edit_campaign(self, campaign_id: str) -> None:
camp = self.app.model.get_campaign(campaign_id) camp = self.app.model.get_campaign(campaign_id)
@ -111,25 +123,46 @@ class CampaignController:
if not campaign_id: if not campaign_id:
return return
camp = self.app.model.get_campaign(campaign_id) camp = self.app.model.get_campaign(campaign_id)
if camp.is_over: war = self.app.model.get_war_by_campaign(campaign_id)
return workflow = CampaignClosureWorkflow(self.app)
try: try:
ties = ClosureService.close_campaign(camp) workflow.start(war, camp)
except RuntimeError as e: except DomainError as e:
QMessageBox.warning(self.app.view, "Cannot close campaign", str(e)) QMessageBox.warning(
return
if ties:
QMessageBox.information(
self.app.view, self.app.view,
"Tie detected", "Deletion forbidden",
"Campaign has unresolved ties.", str(e),
) )
return
self.app.is_dirty = True self.app.is_dirty = True
self.app.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS) 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: def create_campaign_participant(self) -> CampaignParticipant | None:
if not self.app.navigation.selected_campaign_id: if not self.app.navigation.selected_campaign_id:
@ -189,7 +222,7 @@ class CampaignController:
self.app.view, "Invalid name", "Sector name cannot be empty." self.app.view, "Invalid name", "Sector name cannot be empty."
) )
return False return False
# allow same objectives in different fields? # TODO allow same objectives in different fields?
return True return True
def create_sector(self) -> Sector | None: def create_sector(self) -> Sector | None:

View file

@ -3,16 +3,11 @@ from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
from warchron.controller.app_controller import AppController 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.war import War
from warchron.model.campaign import Campaign from warchron.model.campaign import Campaign
from warchron.model.battle import Battle
from warchron.model.round import Round from warchron.model.round import Round
from warchron.model.closure_service import ClosureService from warchron.model.closure_service import ClosureService
from warchron.model.tie_manager import TieResolver from warchron.model.tie_manager import TieResolver
from warchron.controller.dtos import TieContext
class ClosureWorkflow: class ClosureWorkflow:
@ -25,53 +20,33 @@ class RoundClosureWorkflow(ClosureWorkflow):
def start(self, war: War, campaign: Campaign, round: Round) -> None: def start(self, war: War, campaign: Campaign, round: Round) -> None:
ClosureService.check_round_closable(round) ClosureService.check_round_closable(round)
ties = TieResolver.find_round_ties(round, war) ties = TieResolver.find_battle_ties(war, round.id)
while ties: while ties:
contexts = [ bids_map = self.app.rounds.resolve_ties(war, ties)
RoundClosureWorkflow.build_battle_context(campaign, b) for b in ties for tie in ties:
] bids = bids_map[tie.context_id]
resolvable = [] TieResolver.apply_bids(war, tie.context_type, tie.context_id, bids)
for ctx in contexts: TieResolver.resolve_tie_state(
if TieResolver.can_tie_be_resolved(war, ctx.participants): war, tie.context_type, tie.context_id, tie.participants, bids
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( ties = TieResolver.find_battle_ties(war, round.id)
war,
ctx.context_type,
ctx.context_id,
ctx.participants,
)
ties = TieResolver.find_round_ties(round, war)
for battle in round.battles.values(): for battle in round.battles.values():
ClosureService.apply_battle_outcomes(war, campaign, battle) ClosureService.apply_battle_outcomes(war, campaign, battle)
ClosureService.finalize_round(round) ClosureService.finalize_round(round)
@staticmethod
def build_battle_context(campaign: Campaign, battle: Battle) -> TieContext: class CampaignClosureWorkflow(ClosureWorkflow):
if battle.player_1_id is None or battle.player_2_id is None:
raise ForbiddenOperation("Missing player(s) in this battle context.") def start(self, war: War, campaign: Campaign) -> None:
p1 = campaign.participants[battle.player_1_id].war_participant_id ClosureService.check_campaign_closable(campaign)
p2 = campaign.participants[battle.player_2_id].war_participant_id ties = TieResolver.find_campaign_ties(war, campaign.id)
return TieContext( while ties:
context_type=ContextType.BATTLE, bids_map = self.app.campaigns.resolve_ties(war, ties)
context_id=battle.sector_id, for tie in ties:
participants=[p1, p2], 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)

View file

@ -3,8 +3,6 @@ from dataclasses import dataclass
from PyQt6.QtGui import QIcon from PyQt6.QtGui import QIcon
from warchron.constants import ContextType
@dataclass(frozen=True) @dataclass(frozen=True)
class ParticipantOption: class ParticipantOption:
@ -109,8 +107,20 @@ class BattleDTO:
player2_tooltip: str | None = None player2_tooltip: str | None = None
@dataclass @dataclass(frozen=True, slots=True)
class TieContext: class ParticipantScoreDTO:
context_type: ContextType participant_id: str
context_id: str player_name: str
participants: List[str] # war_participant_ids 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]

View file

@ -6,7 +6,8 @@ from PyQt6.QtGui import QIcon
from warchron.constants import ItemType, RefreshScope, Icons, IconName, ContextType from warchron.constants import ItemType, RefreshScope, Icons, IconName, ContextType
from warchron.model.exception import ForbiddenOperation, DomainError 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.round import Round
from warchron.model.war import War from warchron.model.war import War
@ -18,7 +19,6 @@ from warchron.controller.dtos import (
SectorDTO, SectorDTO,
ChoiceDTO, ChoiceDTO,
BattleDTO, BattleDTO,
TieContext,
) )
from warchron.controller.closure_workflow import RoundClosureWorkflow from warchron.controller.closure_workflow import RoundClosureWorkflow
from warchron.view.choice_dialog import ChoiceDialog from warchron.view.choice_dialog import ChoiceDialog
@ -108,7 +108,7 @@ class RoundController:
if TieResolver.was_tie_broken_by_tokens( if TieResolver.was_tie_broken_by_tokens(
war, ContextType.BATTLE, battle.sector_id 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 war, ContextType.BATTLE, battle.sector_id, None
) )
p1_war = None p1_war = None

View file

@ -43,12 +43,13 @@ class Battle:
def is_draw(self) -> bool: def is_draw(self) -> bool:
if self.winner_id is not None: if self.winner_id is not None:
return False return False
# Case 1: score entered → interpreted as unresolved outcome
if self.score and self.score.strip(): if self.score and self.score.strip():
return True return True
# Case 2: explicit draw mention
if self.victory_condition: 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 True
return False return False

View file

@ -187,6 +187,7 @@ class Campaign:
mission: str | None, mission: str | None,
description: str | None, description: str | None,
) -> None: ) -> None:
# TODO raise error if sector used in a closed round (potential tokens)
if self.is_over: if self.is_over:
raise ForbiddenOperation("Can't update sector in a closed campaign.") raise ForbiddenOperation("Can't update sector in a closed campaign.")
sect = self.get_sector(sector_id) sect = self.get_sector(sector_id)
@ -284,6 +285,22 @@ class Campaign:
return rnd return rnd
raise KeyError(f"Round {round_id} not found") 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]: def get_all_rounds(self) -> List[Round]:
return list(self.rounds) return list(self.rounds)
@ -309,14 +326,6 @@ class Campaign:
if rnd: if rnd:
self.rounds.remove(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 # Choice methods
def create_choice(self, round_id: str, participant_id: str) -> Choice: def create_choice(self, round_id: str, participant_id: str) -> Choice:

View file

@ -3,7 +3,7 @@ from typing import List
from warchron.constants import ContextType from warchron.constants import ContextType
from warchron.model.exception import ForbiddenOperation 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_event import InfluenceGained
from warchron.model.war import War from warchron.model.war import War
from warchron.model.campaign import Campaign from warchron.model.campaign import Campaign
@ -27,16 +27,16 @@ class ClosureService:
@staticmethod @staticmethod
def apply_battle_outcomes(war: War, campaign: Campaign, battle: Battle) -> None: def apply_battle_outcomes(war: War, campaign: Campaign, battle: Battle) -> None:
already_granted = any( 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 for e in war.events
) )
if already_granted: if already_granted:
return return
base_winner = None
if battle.winner_id is not None: if battle.winner_id is not None:
base_winner = campaign.participants[battle.winner_id].war_participant_id base_winner = campaign.participants[battle.winner_id].war_participant_id
else: effective_winner = ResultChecker.get_effective_winner_id(
base_winner = None
effective_winner = TieResolver.get_effective_winner_id(
war, war,
ContextType.BATTLE, ContextType.BATTLE,
battle.sector_id, battle.sector_id,
@ -50,7 +50,8 @@ class ClosureService:
InfluenceGained( InfluenceGained(
participant_id=effective_winner, participant_id=effective_winner,
amount=1, 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 # Campaign methods
@staticmethod @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(): if not campaign.all_rounds_finished():
raise RuntimeError("All rounds must be finished to close their campaign") raise ForbiddenOperation(
ties: List[str] = [] "All rounds must be closed to close their campaign"
# for round in campaign.rounds: )
# # compute score
# # if participants have same score @staticmethod
# ties.append( def finalize_campaign(campaign: Campaign) -> None:
# ResolutionContext(
# context_type=ContextType.CAMPAIGN,
# context_id=campaign.id,
# participant_ids=[
# # TODO ref to War.participants at some point
# campaign.participants[campaign_participant_id],
# campaign.participants[campaign_participant_id],
# ],
# )
# )
if ties:
return ties
campaign.is_over = True campaign.is_over = True
return []
# War methods # War methods

View file

@ -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

View file

@ -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.result_checker import ResultChecker
from warchron.model.war import War 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: class ScoreService:
@staticmethod @staticmethod
def compute_victory_points_for_participant(war: "War", participant_id: str) -> int: def _get_battles_for_context(
total = 0 war: War, context_type: ContextType, context_id: str
for campaign in war.campaigns: ) -> Iterator[Battle]:
for round_ in campaign.rounds: if context_type == ContextType.WAR:
for battle in round_.battles.values(): for camp in war.campaigns:
if battle.winner_id == participant_id: for rnd in camp.rounds:
sector = campaign.sectors[battle.sector_id] if not rnd.is_over:
if sector.major_objective_id: continue
total += war.major_value yield from rnd.battles.values()
if sector.minor_objective_id:
total += war.minor_value elif context_type == ContextType.CAMPAIGN:
return total 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 @staticmethod
def compute_narrative_points_for_participant( def compute_scores(
war: "War", participant_id: str war: War, context_type: ContextType, context_id: str
) -> Dict[str, int]: ) -> Dict[str, ParticipantScore]:
totals: Dict[str, int] = {} scores = {
for obj_id in war.objectives: pid: ParticipantScore(
totals[obj_id] = 0 narrative_points={obj_id: 0 for obj_id in war.objectives}
for campaign in war.campaigns: )
for round_ in campaign.rounds: for pid in war.participants
for battle in round_.battles.values(): }
if battle.winner_id == participant_id: battles = ScoreService._get_battles_for_context(war, context_type, context_id)
sector = campaign.sectors[battle.sector_id] for battle in battles:
if sector.major_objective_id: base_winner = None
totals[sector.major_objective_id] += war.major_value if battle.winner_id is not None:
if sector.minor_objective_id: campaign = war.get_campaign_by_campaign_participant(battle.winner_id)
totals[sector.minor_objective_id] += war.minor_value if campaign is not None:
return totals base_winner = campaign.participants[
battle.winner_id
# def compute_round_results(round) ].war_participant_id
winner = ResultChecker.get_effective_winner_id(
# def compute_campaign_winner(campaign) war, ContextType.BATTLE, battle.sector_id, base_winner
)
# def compute_war_winner(war) 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

View file

@ -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.constants import ContextType
from warchron.model.exception import ForbiddenOperation from warchron.model.exception import ForbiddenOperation
from warchron.model.war import War from warchron.model.war import War
from warchron.model.round import Round
from warchron.model.battle import Battle
from warchron.model.war_event import InfluenceSpent, TieResolved from warchron.model.war_event import InfluenceSpent, 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: class TieResolver:
@staticmethod @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 = [] ties = []
for battle in round.battles.values(): for battle in round.battles.values():
if not battle.is_draw(): if not battle.is_draw():
continue continue
resolved = any( if TieResolver.is_tie_resolved(war, ContextType.BATTLE, battle.sector_id):
isinstance(e, TieResolved) continue
and e.context_type == ContextType.BATTLE
and e.context_id == battle.sector_id if campaign is None:
for e in war.events 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 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 @staticmethod
def apply_bids( def apply_bids(
war: War, war: War,
@ -43,69 +101,80 @@ class TieResolver:
participant_id=war_part_id, participant_id=war_part_id,
amount=1, amount=1,
context_type=context_type, 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, 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 @staticmethod
def can_tie_be_resolved(war: War, participants: List[str]) -> bool: def rank_by_tokens(
return any(war.get_influence_tokens(pid) > 0 for pid in participants)
@staticmethod
def get_effective_winner_id(
war: War, war: War,
context_type: ContextType, context_type: ContextType,
context_id: str, context_id: str,
base_winner_id: str | None, participants: List[str],
) -> str | None: ) -> List[List[str]]:
if base_winner_id is not None: spent = {pid: 0 for pid in participants}
return base_winner_id for ev in war.events:
for ev in reversed(war.events):
if ( if (
isinstance(ev, TieResolved) isinstance(ev, InfluenceSpent)
and ev.context_type == context_type and ev.context_type == context_type
and ev.context_id == context_id 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 @staticmethod
def was_tie_broken_by_tokens( def was_tie_broken_by_tokens(
@ -121,3 +190,12 @@ class TieResolver:
): ):
return ev.participant_id is not None return ev.participant_id is not None
return False 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
)

View file

@ -371,6 +371,14 @@ class War:
# Round methods # 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: def add_round(self, campaign_id: str) -> Round:
camp = self.get_campaign(campaign_id) camp = self.get_campaign(campaign_id)
return camp.add_round() return camp.add_round()
@ -413,6 +421,15 @@ class War:
# Battle methods # 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: def create_battle(self, round_id: str, sector_id: str) -> Battle:
camp = self.get_campaign_by_round(round_id) camp = self.get_campaign_by_round(round_id)
if camp is not None: if camp is not None:

View file

@ -15,9 +15,11 @@ def register_event(cls: Type[T]) -> Type[T]:
class WarEvent: class WarEvent:
TYPE = "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.id: str = str(uuid4())
self.participant_id: str | None = participant_id 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() self.timestamp: datetime = datetime.now()
def set_id(self, new_id: str) -> None: def set_id(self, new_id: str) -> None:
@ -34,6 +36,8 @@ class WarEvent:
"type": self.TYPE, "type": self.TYPE,
"id": self.id, "id": self.id,
"participant_id": self.participant_id, "participant_id": self.participant_id,
"context_type": self.context_type,
"context_id": self.context_id,
"timestamp": self.timestamp.isoformat(), "timestamp": self.timestamp.isoformat(),
} }
@ -41,6 +45,8 @@ class WarEvent:
def _base_fromDict(cls: Type[T], ev: T, data: Dict[str, Any]) -> T: def _base_fromDict(cls: Type[T], ev: T, data: Dict[str, Any]) -> T:
ev.id = data["id"] ev.id = data["id"]
ev.participant_id = data["participant_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"]) ev.timestamp = datetime.fromisoformat(data["timestamp"])
return ev return ev
@ -58,21 +64,10 @@ class TieResolved(WarEvent):
TYPE = "TieResolved" TYPE = "TieResolved"
def __init__(self, participant_id: str | None, context_type: str, context_id: str): def __init__(self, participant_id: str | None, context_type: str, context_id: str):
super().__init__(participant_id) super().__init__(participant_id, context_type, context_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]: def toDict(self) -> Dict[str, Any]:
d = super().toDict() d = super().toDict()
d.update(
{
"context_type": self.context_type,
"context_id": self.context_id,
}
)
return d return d
@classmethod @classmethod
@ -89,17 +84,17 @@ class TieResolved(WarEvent):
class InfluenceGained(WarEvent): class InfluenceGained(WarEvent):
TYPE = "InfluenceGained" TYPE = "InfluenceGained"
def __init__(self, participant_id: str, amount: int, source: str): def __init__(
super().__init__(participant_id) self, participant_id: str, amount: int, context_type: str, context_id: str
):
super().__init__(participant_id, context_type, context_id)
self.amount = amount self.amount = amount
self.source = source # "battle", "tie_resolution", etc.
def toDict(self) -> Dict[str, Any]: def toDict(self) -> Dict[str, Any]:
d = super().toDict() d = super().toDict()
d.update( d.update(
{ {
"amount": self.amount, "amount": self.amount,
"source": self.source,
} }
) )
return d return d
@ -108,8 +103,9 @@ class InfluenceGained(WarEvent):
def fromDict(cls, data: Dict[str, Any]) -> InfluenceGained: def fromDict(cls, data: Dict[str, Any]) -> InfluenceGained:
ev = cls( ev = cls(
data["participant_id"], data["participant_id"],
data["amount"], int(data["amount"]),
data["source"], data["context_type"],
data["context_id"],
) )
return cls._base_fromDict(ev, data) return cls._base_fromDict(ev, data)
@ -118,17 +114,17 @@ class InfluenceGained(WarEvent):
class InfluenceSpent(WarEvent): class InfluenceSpent(WarEvent):
TYPE = "InfluenceSpent" TYPE = "InfluenceSpent"
def __init__(self, participant_id: str, amount: int, context_type: str): def __init__(
super().__init__(participant_id) self, participant_id: str, amount: int, context_type: str, context_id: str
):
super().__init__(participant_id, context_type, context_id)
self.amount = amount self.amount = amount
self.context_type = context_type # "battle_tie", "campaign_tie", etc.
def toDict(self) -> Dict[str, Any]: def toDict(self) -> Dict[str, Any]:
d = super().toDict() d = super().toDict()
d.update( d.update(
{ {
"amount": self.amount, "amount": self.amount,
"context_type": self.context_type,
} }
) )
return d return d
@ -137,7 +133,8 @@ class InfluenceSpent(WarEvent):
def fromDict(cls, data: Dict[str, Any]) -> InfluenceSpent: def fromDict(cls, data: Dict[str, Any]) -> InfluenceSpent:
ev = cls( ev = cls(
data["participant_id"], data["participant_id"],
data["amount"], int(data["amount"]),
data["context_type"], data["context_type"],
data["context_id"],
) )
return cls._base_fromDict(ev, data) return cls._base_fromDict(ev, data)

View file

@ -1,6 +1,15 @@
from typing import List, Dict 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 PyQt6.QtCore import Qt
from warchron.constants import Icons, IconName, ContextType, RESOURCES_DIR from warchron.constants import Icons, IconName, ContextType, RESOURCES_DIR
@ -20,32 +29,47 @@ class TieDialog(QDialog):
) -> None: ) -> None:
super().__init__(parent) super().__init__(parent)
self._context_id = context_id self._context_id = context_id
self._p1_id = players[0].id self._checkboxes: Dict[str, QCheckBox] = {}
self._p2_id = players[1].id
self.ui: Ui_tieDialog = Ui_tieDialog() self.ui: Ui_tieDialog = Ui_tieDialog()
self.ui.setupUi(self) # type: ignore 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'<img src="{icon_path}" width="16" height="16"> 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.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'<img src="{icon_path}" width="16" height="16"> 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]: def get_bids(self) -> Dict[str, bool]:
return { return {pid: checkbox.isChecked() for pid, checkbox in self._checkboxes.items()}
self._p1_id: self.ui.tokenSpend_1.isChecked(),
self._p2_id: self.ui.tokenSpend_2.isChecked(),
}
@staticmethod @staticmethod
def _get_context_title(context_type: ContextType) -> str: def _get_context_title(context_type: ContextType) -> str:

View file

@ -13,12 +13,16 @@ class Ui_tieDialog(object):
def setupUi(self, tieDialog): def setupUi(self, tieDialog):
tieDialog.setObjectName("tieDialog") tieDialog.setObjectName("tieDialog")
tieDialog.setWindowModality(QtCore.Qt.WindowModality.ApplicationModal) tieDialog.setWindowModality(QtCore.Qt.WindowModality.ApplicationModal)
tieDialog.resize(477, 174) tieDialog.resize(481, 155)
icon = QtGui.QIcon() 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) tieDialog.setWindowIcon(icon)
self.verticalLayout_5 = QtWidgets.QVBoxLayout(tieDialog) self.gridLayout = QtWidgets.QGridLayout(tieDialog)
self.verticalLayout_5.setObjectName("verticalLayout_5") self.gridLayout.setObjectName("gridLayout")
self.horizontalLayout_3 = QtWidgets.QHBoxLayout() self.horizontalLayout_3 = QtWidgets.QHBoxLayout()
self.horizontalLayout_3.setObjectName("horizontalLayout_3") self.horizontalLayout_3.setObjectName("horizontalLayout_3")
self.tieContext = QtWidgets.QLabel(parent=tieDialog) self.tieContext = QtWidgets.QLabel(parent=tieDialog)
@ -29,87 +33,47 @@ class Ui_tieDialog(object):
self.tieContext.setFont(font) self.tieContext.setFont(font)
self.tieContext.setObjectName("tieContext") self.tieContext.setObjectName("tieContext")
self.horizontalLayout_3.addWidget(self.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.horizontalLayout_3.addItem(spacerItem)
self.verticalLayout_5.addLayout(self.horizontalLayout_3) self.gridLayout.addLayout(self.horizontalLayout_3, 0, 0, 1, 1)
self.horizontalLayout_4 = QtWidgets.QHBoxLayout() self.playersScrollArea = QtWidgets.QScrollArea(parent=tieDialog)
self.horizontalLayout_4.setObjectName("horizontalLayout_4") self.playersScrollArea.setWidgetResizable(True)
self.groupBox_1 = QtWidgets.QGroupBox(parent=tieDialog) self.playersScrollArea.setObjectName("playersScrollArea")
self.groupBox_1.setObjectName("groupBox_1") self.scrollAreaWidgetContents = QtWidgets.QWidget()
self.horizontalLayout = QtWidgets.QHBoxLayout(self.groupBox_1) self.scrollAreaWidgetContents.setGeometry(QtCore.QRect(0, 0, 461, 78))
self.horizontalLayout.setObjectName("horizontalLayout") self.scrollAreaWidgetContents.setObjectName("scrollAreaWidgetContents")
self.verticalLayout = QtWidgets.QVBoxLayout() self.playersGridLayout = QtWidgets.QGridLayout(self.scrollAreaWidgetContents)
self.verticalLayout.setObjectName("verticalLayout") self.playersGridLayout.setObjectName("playersGridLayout")
self.label_5 = QtWidgets.QLabel(parent=self.groupBox_1) self.playersScrollArea.setWidget(self.scrollAreaWidgetContents)
self.label_5.setObjectName("label_5") self.gridLayout.addWidget(self.playersScrollArea, 1, 0, 1, 1)
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 = QtWidgets.QDialogButtonBox(parent=tieDialog)
self.buttonBox.setOrientation(QtCore.Qt.Orientation.Horizontal) 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.buttonBox.setObjectName("buttonBox")
self.verticalLayout_5.addWidget(self.buttonBox) self.gridLayout.addWidget(self.buttonBox, 2, 0, 1, 1)
self.retranslateUi(tieDialog) self.retranslateUi(tieDialog)
self.buttonBox.accepted.connect(tieDialog.accept) # type: ignore self.buttonBox.accepted.connect(tieDialog.accept) # type: ignore
self.buttonBox.rejected.connect(tieDialog.reject) # type: ignore self.buttonBox.rejected.connect(tieDialog.reject) # type: ignore
QtCore.QMetaObject.connectSlotsByName(tieDialog) QtCore.QMetaObject.connectSlotsByName(tieDialog)
def retranslateUi(self, tieDialog): def retranslateUi(self, tieDialog):
_translate = QtCore.QCoreApplication.translate _translate = QtCore.QCoreApplication.translate
tieDialog.setWindowTitle(_translate("tieDialog", "Tie")) tieDialog.setWindowTitle(_translate("tieDialog", "Tie"))
self.tieContext.setText(_translate("tieDialog", "Battle 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__": if __name__ == "__main__":
import sys import sys
app = QtWidgets.QApplication(sys.argv) app = QtWidgets.QApplication(sys.argv)
tieDialog = QtWidgets.QDialog() tieDialog = QtWidgets.QDialog()
ui = Ui_tieDialog() ui = Ui_tieDialog()

View file

@ -9,8 +9,8 @@
<rect> <rect>
<x>0</x> <x>0</x>
<y>0</y> <y>0</y>
<width>477</width> <width>481</width>
<height>174</height> <height>155</height>
</rect> </rect>
</property> </property>
<property name="windowTitle"> <property name="windowTitle">
@ -20,8 +20,8 @@
<iconset> <iconset>
<normaloff>../resources/warchron_logo.png</normaloff>../resources/warchron_logo.png</iconset> <normaloff>../resources/warchron_logo.png</normaloff>../resources/warchron_logo.png</iconset>
</property> </property>
<layout class="QVBoxLayout" name="verticalLayout_5"> <layout class="QGridLayout" name="gridLayout">
<item> <item row="0" column="0">
<layout class="QHBoxLayout" name="horizontalLayout_3"> <layout class="QHBoxLayout" name="horizontalLayout_3">
<item> <item>
<widget class="QLabel" name="tieContext"> <widget class="QLabel" name="tieContext">
@ -52,101 +52,25 @@
</item> </item>
</layout> </layout>
</item> </item>
<item> <item row="1" column="0">
<layout class="QHBoxLayout" name="horizontalLayout_4"> <widget class="QScrollArea" name="playersScrollArea">
<item> <property name="widgetResizable">
<widget class="QGroupBox" name="groupBox_1"> <bool>true</bool>
<property name="title"> </property>
<string>Player 1</string> <widget class="QWidget" name="scrollAreaWidgetContents">
</property> <property name="geometry">
<layout class="QHBoxLayout" name="horizontalLayout"> <rect>
<item> <x>0</x>
<layout class="QVBoxLayout" name="verticalLayout"> <y>0</y>
<item> <width>461</width>
<widget class="QLabel" name="label_5"> <height>78</height>
<property name="text"> </rect>
<string>Spend token</string> </property>
</property> <layout class="QVBoxLayout" name="playersGridLayout"/>
</widget> </widget>
</item> </widget>
<item>
<widget class="QLabel" name="label_2">
<property name="text">
<string>Remaining token(s)</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="QCheckBox" name="tokenSpend_1">
<property name="text">
<string/>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="tokenCount_1">
<property name="enabled">
<bool>false</bool>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupBox_2">
<property name="title">
<string>Player 2</string>
</property>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<widget class="QLabel" name="label_6">
<property name="text">
<string>Spend token</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label_3">
<property name="text">
<string>Remaining token(s)</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QVBoxLayout" name="verticalLayout_4">
<item>
<widget class="QCheckBox" name="tokenSpend_2">
<property name="text">
<string/>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="tokenCount_2">
<property name="enabled">
<bool>false</bool>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</item>
</layout>
</item> </item>
<item> <item row="2" column="0">
<widget class="QDialogButtonBox" name="buttonBox"> <widget class="QDialogButtonBox" name="buttonBox">
<property name="orientation"> <property name="orientation">
<enum>Qt::Horizontal</enum> <enum>Qt::Horizontal</enum>

View file

@ -15,10 +15,10 @@ from warchron.controller.dtos import (
WarDTO, WarDTO,
WarParticipantDTO, WarParticipantDTO,
ObjectiveDTO, ObjectiveDTO,
CampaignParticipantDTO,
SectorDTO, SectorDTO,
ChoiceDTO, ChoiceDTO,
BattleDTO, BattleDTO,
CampaignParticipantScoreDTO,
) )
from warchron.view.helpers import ( from warchron.view.helpers import (
format_campaign_label, format_campaign_label,
@ -464,19 +464,33 @@ class View(QtWidgets.QMainWindow, Ui_MainWindow):
table.resizeColumnsToContents() table.resizeColumnsToContents()
def display_campaign_participants( def display_campaign_participants(
self, participants: List[CampaignParticipantDTO] self,
participants: List[CampaignParticipantScoreDTO],
objectives: List[ObjectiveDTO],
) -> None: ) -> None:
table = self.campaignParticipantsTable table = self.campaignParticipantsTable
table.clearContents() 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)) table.setRowCount(len(participants))
for row, part in enumerate(participants): for row, part in enumerate(participants):
name_item = QtWidgets.QTableWidgetItem(part.player_name) name_item = QtWidgets.QTableWidgetItem(part.player_name)
lead_item = QtWidgets.QTableWidgetItem(part.leader) lead_item = QtWidgets.QTableWidgetItem(part.leader)
theme_item = QtWidgets.QTableWidgetItem(part.theme) 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, 0, name_item)
table.setItem(row, 1, lead_item) table.setItem(row, 1, lead_item)
table.setItem(row, 2, theme_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() table.resizeColumnsToContents()
# Round page # Round page