Compare commits
No commits in common. "60992c22df206ea2d4c5bff5c6787fca7b69125a" and "45c1d69a3f1b1417caf80138edb34eca210b4252" have entirely different histories.
60992c22df
...
45c1d69a3f
18 changed files with 451 additions and 541 deletions
16
README.md
16
README.md
|
|
@ -7,12 +7,16 @@ 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 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.
|
||||
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
|
||||
|
||||
## Installation
|
||||
|
||||
|
|
|
|||
|
|
@ -96,7 +96,6 @@ 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:
|
||||
|
|
@ -117,7 +116,6 @@ 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:
|
||||
|
|
|
|||
|
|
@ -1,30 +1,25 @@
|
|||
from typing import List, Dict, TYPE_CHECKING
|
||||
from typing import List, TYPE_CHECKING
|
||||
|
||||
from PyQt6.QtWidgets import QMessageBox, QDialog
|
||||
|
||||
from warchron.constants import RefreshScope, ContextType, ItemType
|
||||
from warchron.constants import RefreshScope
|
||||
|
||||
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.tie_manager import TieContext, TieResolver
|
||||
from warchron.model.score_service import ScoreService
|
||||
from warchron.model.closure_service import ClosureService
|
||||
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:
|
||||
|
|
@ -50,28 +45,17 @@ class CampaignController:
|
|||
for sect in sectors
|
||||
]
|
||||
self.app.view.display_campaign_sectors(sectors_for_display)
|
||||
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),
|
||||
)
|
||||
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 "",
|
||||
)
|
||||
objectives = [
|
||||
ObjectiveDTO(o.id, o.name, o.description) for o in war.get_all_objectives()
|
||||
for p in camp_parts
|
||||
]
|
||||
self.app.view.display_campaign_participants(rows, objectives)
|
||||
self.app.view.display_campaign_participants(participants_for_display)
|
||||
self.app.view.endCampaignBtn.setEnabled(not camp.is_over)
|
||||
|
||||
def _validate_campaign_inputs(self, name: str, month: int) -> bool:
|
||||
|
|
@ -105,6 +89,10 @@ 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)
|
||||
|
|
@ -123,46 +111,25 @@ class CampaignController:
|
|||
if not campaign_id:
|
||||
return
|
||||
camp = self.app.model.get_campaign(campaign_id)
|
||||
war = self.app.model.get_war_by_campaign(campaign_id)
|
||||
workflow = CampaignClosureWorkflow(self.app)
|
||||
if camp.is_over:
|
||||
return
|
||||
try:
|
||||
workflow.start(war, camp)
|
||||
except DomainError as e:
|
||||
QMessageBox.warning(
|
||||
ties = ClosureService.close_campaign(camp)
|
||||
except RuntimeError as e:
|
||||
QMessageBox.warning(self.app.view, "Cannot close campaign", str(e))
|
||||
return
|
||||
if ties:
|
||||
QMessageBox.information(
|
||||
self.app.view,
|
||||
"Deletion forbidden",
|
||||
str(e),
|
||||
"Tie detected",
|
||||
"Campaign has unresolved ties.",
|
||||
)
|
||||
return
|
||||
self.app.is_dirty = True
|
||||
self.app.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS)
|
||||
self.app.navigation.refresh_and_select(
|
||||
RefreshScope.WARS_TREE, item_type=ItemType.CAMPAIGN, item_id=campaign_id
|
||||
)
|
||||
self.app.navigation.refresh(RefreshScope.WARS_TREE)
|
||||
|
||||
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
|
||||
# Campaign participant methods
|
||||
|
||||
def create_campaign_participant(self) -> CampaignParticipant | None:
|
||||
if not self.app.navigation.selected_campaign_id:
|
||||
|
|
@ -222,7 +189,7 @@ class CampaignController:
|
|||
self.app.view, "Invalid name", "Sector name cannot be empty."
|
||||
)
|
||||
return False
|
||||
# TODO allow same objectives in different fields?
|
||||
# allow same objectives in different fields?
|
||||
return True
|
||||
|
||||
def create_sector(self) -> Sector | None:
|
||||
|
|
|
|||
|
|
@ -3,11 +3,16 @@ 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:
|
||||
|
|
@ -20,33 +25,53 @@ class RoundClosureWorkflow(ClosureWorkflow):
|
|||
|
||||
def start(self, war: War, campaign: Campaign, round: Round) -> None:
|
||||
ClosureService.check_round_closable(round)
|
||||
ties = TieResolver.find_battle_ties(war, round.id)
|
||||
ties = TieResolver.find_round_ties(round, war)
|
||||
while ties:
|
||||
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
|
||||
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,
|
||||
)
|
||||
ties = TieResolver.find_battle_ties(war, round.id)
|
||||
TieResolver.try_tie_break(
|
||||
war,
|
||||
ctx.context_type,
|
||||
ctx.context_id,
|
||||
ctx.participants,
|
||||
)
|
||||
ties = TieResolver.find_round_ties(round, war)
|
||||
for battle in round.battles.values():
|
||||
ClosureService.apply_battle_outcomes(war, campaign, battle)
|
||||
ClosureService.finalize_round(round)
|
||||
|
||||
|
||||
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)
|
||||
@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],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ from dataclasses import dataclass
|
|||
|
||||
from PyQt6.QtGui import QIcon
|
||||
|
||||
from warchron.constants import ContextType
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParticipantOption:
|
||||
|
|
@ -107,20 +109,8 @@ class BattleDTO:
|
|||
player2_tooltip: str | None = None
|
||||
|
||||
|
||||
@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]
|
||||
@dataclass
|
||||
class TieContext:
|
||||
context_type: ContextType
|
||||
context_id: str
|
||||
participants: List[str] # war_participant_ids
|
||||
|
|
|
|||
|
|
@ -6,8 +6,7 @@ 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, TieContext
|
||||
from warchron.model.result_checker import ResultChecker
|
||||
from warchron.model.tie_manager import TieResolver
|
||||
from warchron.model.round import Round
|
||||
from warchron.model.war import War
|
||||
|
||||
|
|
@ -19,6 +18,7 @@ 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 = ResultChecker.get_effective_winner_id(
|
||||
effective_winner = TieResolver.get_effective_winner_id(
|
||||
war, ContextType.BATTLE, battle.sector_id, None
|
||||
)
|
||||
p1_war = None
|
||||
|
|
|
|||
|
|
@ -43,13 +43,12 @@ 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 any(
|
||||
keyword in (self.victory_condition or "").casefold()
|
||||
for keyword in ["draw", "tie", "square"]
|
||||
):
|
||||
if "draw" in self.victory_condition.casefold():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
|
|
|||
|
|
@ -187,7 +187,6 @@ 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)
|
||||
|
|
@ -285,22 +284,6 @@ 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)
|
||||
|
||||
|
|
@ -326,6 +309,14 @@ 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:
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from typing import List
|
|||
|
||||
from warchron.constants import ContextType
|
||||
from warchron.model.exception import ForbiddenOperation
|
||||
from warchron.model.result_checker import ResultChecker
|
||||
from warchron.model.tie_manager import TieResolver
|
||||
from warchron.model.war_event import InfluenceGained
|
||||
from warchron.model.war import War
|
||||
from warchron.model.campaign import Campaign
|
||||
|
|
@ -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.context_id == f"battle:{battle.sector_id}"
|
||||
isinstance(e, InfluenceGained) and e.source == 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
|
||||
effective_winner = ResultChecker.get_effective_winner_id(
|
||||
else:
|
||||
base_winner = None
|
||||
effective_winner = TieResolver.get_effective_winner_id(
|
||||
war,
|
||||
ContextType.BATTLE,
|
||||
battle.sector_id,
|
||||
|
|
@ -50,8 +50,7 @@ class ClosureService:
|
|||
InfluenceGained(
|
||||
participant_id=effective_winner,
|
||||
amount=1,
|
||||
context_type=ContextType.BATTLE,
|
||||
context_id=f"battle:{battle.sector_id}",
|
||||
source=f"battle:{battle.sector_id}",
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -62,17 +61,28 @@ class ClosureService:
|
|||
# Campaign methods
|
||||
|
||||
@staticmethod
|
||||
def check_campaign_closable(campaign: Campaign) -> None:
|
||||
if campaign.is_over:
|
||||
raise ForbiddenOperation("Campaign already closed")
|
||||
def close_campaign(campaign: Campaign) -> List[str]:
|
||||
if not campaign.all_rounds_finished():
|
||||
raise ForbiddenOperation(
|
||||
"All rounds must be closed to close their campaign"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def finalize_campaign(campaign: Campaign) -> None:
|
||||
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
|
||||
campaign.is_over = True
|
||||
return []
|
||||
|
||||
# War methods
|
||||
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,77 +1,45 @@
|
|||
from typing import Dict, Iterator
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, TYPE_CHECKING
|
||||
|
||||
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)
|
||||
if TYPE_CHECKING:
|
||||
from warchron.model.war import War
|
||||
|
||||
|
||||
class ScoreService:
|
||||
|
||||
@staticmethod
|
||||
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
|
||||
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
|
||||
|
||||
@staticmethod
|
||||
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
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -1,89 +1,31 @@
|
|||
from typing import List, Dict, DefaultDict
|
||||
from dataclasses import dataclass
|
||||
from collections import defaultdict
|
||||
from typing import List, Dict
|
||||
|
||||
from warchron.constants import ContextType
|
||||
from warchron.model.exception import ForbiddenOperation
|
||||
from warchron.model.war import War
|
||||
from warchron.model.round import Round
|
||||
from warchron.model.battle import Battle
|
||||
from warchron.model.war_event import InfluenceSpent, TieResolved
|
||||
from warchron.model.score_service import ScoreService
|
||||
|
||||
|
||||
@dataclass
|
||||
class TieContext:
|
||||
context_type: ContextType
|
||||
context_id: str
|
||||
participants: List[str] # war_participant_ids
|
||||
|
||||
|
||||
class TieResolver:
|
||||
|
||||
@staticmethod
|
||||
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)
|
||||
def find_round_ties(round: Round, war: War) -> List[Battle]:
|
||||
ties = []
|
||||
for battle in round.battles.values():
|
||||
if not battle.is_draw():
|
||||
continue
|
||||
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],
|
||||
)
|
||||
resolved = any(
|
||||
isinstance(e, TieResolved)
|
||||
and e.context_type == ContextType.BATTLE
|
||||
and e.context_id == battle.sector_id
|
||||
for e in war.events
|
||||
)
|
||||
if not resolved:
|
||||
ties.append(battle)
|
||||
return ties
|
||||
|
||||
@staticmethod
|
||||
def 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,
|
||||
|
|
@ -101,80 +43,69 @@ class TieResolver:
|
|||
participant_id=war_part_id,
|
||||
amount=1,
|
||||
context_type=context_type,
|
||||
context_id=context_id,
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def rank_by_tokens(
|
||||
def try_tie_break(
|
||||
war: War,
|
||||
context_type: ContextType,
|
||||
context_id: str,
|
||||
participants: List[str],
|
||||
) -> List[List[str]]:
|
||||
spent = {pid: 0 for pid in participants}
|
||||
for ev in war.events:
|
||||
participants: List[str], # war_participant_ids
|
||||
) -> bool:
|
||||
spent: Dict[str, int] = {}
|
||||
for war_part_id in participants:
|
||||
spent[war_part_id] = sum(
|
||||
e.amount
|
||||
for e in war.events
|
||||
if isinstance(e, InfluenceSpent)
|
||||
and e.participant_id == war_part_id
|
||||
and e.context_type == context_type
|
||||
)
|
||||
values = set(spent.values())
|
||||
if values == {0}: # no bid = confirmed draw
|
||||
war.events.append(
|
||||
TieResolved(
|
||||
participant_id=None,
|
||||
context_type=context_type,
|
||||
context_id=context_id,
|
||||
)
|
||||
)
|
||||
return True
|
||||
if len(values) == 1: # tie again, continue
|
||||
return False
|
||||
winner = max(spent.items(), key=lambda item: item[1])[0]
|
||||
war.events.append(
|
||||
TieResolved(
|
||||
participant_id=winner,
|
||||
context_type=context_type,
|
||||
context_id=context_id,
|
||||
)
|
||||
)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def can_tie_be_resolved(war: War, participants: List[str]) -> bool:
|
||||
return any(war.get_influence_tokens(pid) > 0 for pid in participants)
|
||||
|
||||
@staticmethod
|
||||
def get_effective_winner_id(
|
||||
war: War,
|
||||
context_type: ContextType,
|
||||
context_id: str,
|
||||
base_winner_id: str | None,
|
||||
) -> str | None:
|
||||
if base_winner_id is not None:
|
||||
return base_winner_id
|
||||
for ev in reversed(war.events):
|
||||
if (
|
||||
isinstance(ev, InfluenceSpent)
|
||||
isinstance(ev, TieResolved)
|
||||
and ev.context_type == context_type
|
||||
and ev.context_id == context_id
|
||||
and ev.participant_id in spent
|
||||
):
|
||||
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 ev.participant_id # None if confirmed draw
|
||||
|
||||
@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)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def was_tie_broken_by_tokens(
|
||||
|
|
@ -190,12 +121,3 @@ 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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -371,14 +371,6 @@ 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()
|
||||
|
|
@ -421,15 +413,6 @@ 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:
|
||||
|
|
|
|||
|
|
@ -15,11 +15,9 @@ def register_event(cls: Type[T]) -> Type[T]:
|
|||
class WarEvent:
|
||||
TYPE = "WarEvent"
|
||||
|
||||
def __init__(self, participant_id: str | None, context_type: str, context_id: str):
|
||||
def __init__(self, participant_id: str | None = None):
|
||||
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:
|
||||
|
|
@ -36,8 +34,6 @@ 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(),
|
||||
}
|
||||
|
||||
|
|
@ -45,8 +41,6 @@ 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
|
||||
|
||||
|
|
@ -64,10 +58,21 @@ class TieResolved(WarEvent):
|
|||
TYPE = "TieResolved"
|
||||
|
||||
def __init__(self, participant_id: str | None, context_type: str, context_id: str):
|
||||
super().__init__(participant_id, context_type, context_id)
|
||||
super().__init__(participant_id)
|
||||
self.participant_id: str | None = (
|
||||
participant_id # winner or None (confirmed tie)
|
||||
)
|
||||
self.context_type = context_type # battle, round, campaign, war
|
||||
self.context_id = context_id
|
||||
|
||||
def toDict(self) -> Dict[str, Any]:
|
||||
d = super().toDict()
|
||||
d.update(
|
||||
{
|
||||
"context_type": self.context_type,
|
||||
"context_id": self.context_id,
|
||||
}
|
||||
)
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
|
|
@ -84,17 +89,17 @@ class TieResolved(WarEvent):
|
|||
class InfluenceGained(WarEvent):
|
||||
TYPE = "InfluenceGained"
|
||||
|
||||
def __init__(
|
||||
self, participant_id: str, amount: int, context_type: str, context_id: str
|
||||
):
|
||||
super().__init__(participant_id, context_type, context_id)
|
||||
def __init__(self, participant_id: str, amount: int, source: str):
|
||||
super().__init__(participant_id)
|
||||
self.amount = amount
|
||||
self.source = source # "battle", "tie_resolution", etc.
|
||||
|
||||
def toDict(self) -> Dict[str, Any]:
|
||||
d = super().toDict()
|
||||
d.update(
|
||||
{
|
||||
"amount": self.amount,
|
||||
"source": self.source,
|
||||
}
|
||||
)
|
||||
return d
|
||||
|
|
@ -103,9 +108,8 @@ class InfluenceGained(WarEvent):
|
|||
def fromDict(cls, data: Dict[str, Any]) -> InfluenceGained:
|
||||
ev = cls(
|
||||
data["participant_id"],
|
||||
int(data["amount"]),
|
||||
data["context_type"],
|
||||
data["context_id"],
|
||||
data["amount"],
|
||||
data["source"],
|
||||
)
|
||||
return cls._base_fromDict(ev, data)
|
||||
|
||||
|
|
@ -114,17 +118,17 @@ class InfluenceGained(WarEvent):
|
|||
class InfluenceSpent(WarEvent):
|
||||
TYPE = "InfluenceSpent"
|
||||
|
||||
def __init__(
|
||||
self, participant_id: str, amount: int, context_type: str, context_id: str
|
||||
):
|
||||
super().__init__(participant_id, context_type, context_id)
|
||||
def __init__(self, participant_id: str, amount: int, context_type: str):
|
||||
super().__init__(participant_id)
|
||||
self.amount = amount
|
||||
self.context_type = context_type # "battle_tie", "campaign_tie", etc.
|
||||
|
||||
def toDict(self) -> Dict[str, Any]:
|
||||
d = super().toDict()
|
||||
d.update(
|
||||
{
|
||||
"amount": self.amount,
|
||||
"context_type": self.context_type,
|
||||
}
|
||||
)
|
||||
return d
|
||||
|
|
@ -133,8 +137,7 @@ class InfluenceSpent(WarEvent):
|
|||
def fromDict(cls, data: Dict[str, Any]) -> InfluenceSpent:
|
||||
ev = cls(
|
||||
data["participant_id"],
|
||||
int(data["amount"]),
|
||||
data["amount"],
|
||||
data["context_type"],
|
||||
data["context_id"],
|
||||
)
|
||||
return cls._base_fromDict(ev, data)
|
||||
|
|
|
|||
|
|
@ -1,15 +1,6 @@
|
|||
from typing import List, Dict
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget,
|
||||
QDialog,
|
||||
QCheckBox,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QVBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
)
|
||||
from PyQt6.QtWidgets import QWidget, QDialog
|
||||
from PyQt6.QtCore import Qt
|
||||
|
||||
from warchron.constants import Icons, IconName, ContextType, RESOURCES_DIR
|
||||
|
|
@ -29,47 +20,32 @@ class TieDialog(QDialog):
|
|||
) -> None:
|
||||
super().__init__(parent)
|
||||
self._context_id = context_id
|
||||
self._checkboxes: Dict[str, QCheckBox] = {}
|
||||
self._p1_id = players[0].id
|
||||
self._p2_id = players[1].id
|
||||
self.ui: Ui_tieDialog = Ui_tieDialog()
|
||||
self.ui.setupUi(self) # type: ignore
|
||||
self.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()
|
||||
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))
|
||||
|
||||
def get_bids(self) -> Dict[str, bool]:
|
||||
return {pid: checkbox.isChecked() for pid, checkbox in self._checkboxes.items()}
|
||||
return {
|
||||
self._p1_id: self.ui.tokenSpend_1.isChecked(),
|
||||
self._p2_id: self.ui.tokenSpend_2.isChecked(),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _get_context_title(context_type: ContextType) -> str:
|
||||
|
|
|
|||
|
|
@ -13,16 +13,12 @@ class Ui_tieDialog(object):
|
|||
def setupUi(self, tieDialog):
|
||||
tieDialog.setObjectName("tieDialog")
|
||||
tieDialog.setWindowModality(QtCore.Qt.WindowModality.ApplicationModal)
|
||||
tieDialog.resize(481, 155)
|
||||
tieDialog.resize(477, 174)
|
||||
icon = QtGui.QIcon()
|
||||
icon.addPixmap(
|
||||
QtGui.QPixmap(".\\src\\warchron\\view\\ui\\../resources/warchron_logo.png"),
|
||||
QtGui.QIcon.Mode.Normal,
|
||||
QtGui.QIcon.State.Off,
|
||||
)
|
||||
icon.addPixmap(QtGui.QPixmap(".\\src\\warchron\\view\\ui\\../resources/warchron_logo.png"), QtGui.QIcon.Mode.Normal, QtGui.QIcon.State.Off)
|
||||
tieDialog.setWindowIcon(icon)
|
||||
self.gridLayout = QtWidgets.QGridLayout(tieDialog)
|
||||
self.gridLayout.setObjectName("gridLayout")
|
||||
self.verticalLayout_5 = QtWidgets.QVBoxLayout(tieDialog)
|
||||
self.verticalLayout_5.setObjectName("verticalLayout_5")
|
||||
self.horizontalLayout_3 = QtWidgets.QHBoxLayout()
|
||||
self.horizontalLayout_3.setObjectName("horizontalLayout_3")
|
||||
self.tieContext = QtWidgets.QLabel(parent=tieDialog)
|
||||
|
|
@ -33,47 +29,87 @@ 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.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.verticalLayout_5.addLayout(self.horizontalLayout_3)
|
||||
self.horizontalLayout_4 = QtWidgets.QHBoxLayout()
|
||||
self.horizontalLayout_4.setObjectName("horizontalLayout_4")
|
||||
self.groupBox_1 = QtWidgets.QGroupBox(parent=tieDialog)
|
||||
self.groupBox_1.setObjectName("groupBox_1")
|
||||
self.horizontalLayout = QtWidgets.QHBoxLayout(self.groupBox_1)
|
||||
self.horizontalLayout.setObjectName("horizontalLayout")
|
||||
self.verticalLayout = QtWidgets.QVBoxLayout()
|
||||
self.verticalLayout.setObjectName("verticalLayout")
|
||||
self.label_5 = QtWidgets.QLabel(parent=self.groupBox_1)
|
||||
self.label_5.setObjectName("label_5")
|
||||
self.verticalLayout.addWidget(self.label_5)
|
||||
self.label_2 = QtWidgets.QLabel(parent=self.groupBox_1)
|
||||
self.label_2.setObjectName("label_2")
|
||||
self.verticalLayout.addWidget(self.label_2)
|
||||
self.horizontalLayout.addLayout(self.verticalLayout)
|
||||
self.verticalLayout_2 = QtWidgets.QVBoxLayout()
|
||||
self.verticalLayout_2.setObjectName("verticalLayout_2")
|
||||
self.tokenSpend_1 = QtWidgets.QCheckBox(parent=self.groupBox_1)
|
||||
self.tokenSpend_1.setText("")
|
||||
self.tokenSpend_1.setObjectName("tokenSpend_1")
|
||||
self.verticalLayout_2.addWidget(self.tokenSpend_1)
|
||||
self.tokenCount_1 = QtWidgets.QLineEdit(parent=self.groupBox_1)
|
||||
self.tokenCount_1.setEnabled(False)
|
||||
self.tokenCount_1.setObjectName("tokenCount_1")
|
||||
self.verticalLayout_2.addWidget(self.tokenCount_1)
|
||||
self.horizontalLayout.addLayout(self.verticalLayout_2)
|
||||
self.horizontalLayout_4.addWidget(self.groupBox_1)
|
||||
self.groupBox_2 = QtWidgets.QGroupBox(parent=tieDialog)
|
||||
self.groupBox_2.setObjectName("groupBox_2")
|
||||
self.horizontalLayout_2 = QtWidgets.QHBoxLayout(self.groupBox_2)
|
||||
self.horizontalLayout_2.setObjectName("horizontalLayout_2")
|
||||
self.verticalLayout_3 = QtWidgets.QVBoxLayout()
|
||||
self.verticalLayout_3.setObjectName("verticalLayout_3")
|
||||
self.label_6 = QtWidgets.QLabel(parent=self.groupBox_2)
|
||||
self.label_6.setObjectName("label_6")
|
||||
self.verticalLayout_3.addWidget(self.label_6)
|
||||
self.label_3 = QtWidgets.QLabel(parent=self.groupBox_2)
|
||||
self.label_3.setObjectName("label_3")
|
||||
self.verticalLayout_3.addWidget(self.label_3)
|
||||
self.horizontalLayout_2.addLayout(self.verticalLayout_3)
|
||||
self.verticalLayout_4 = QtWidgets.QVBoxLayout()
|
||||
self.verticalLayout_4.setObjectName("verticalLayout_4")
|
||||
self.tokenSpend_2 = QtWidgets.QCheckBox(parent=self.groupBox_2)
|
||||
self.tokenSpend_2.setText("")
|
||||
self.tokenSpend_2.setObjectName("tokenSpend_2")
|
||||
self.verticalLayout_4.addWidget(self.tokenSpend_2)
|
||||
self.tokenCount_2 = QtWidgets.QLineEdit(parent=self.groupBox_2)
|
||||
self.tokenCount_2.setEnabled(False)
|
||||
self.tokenCount_2.setObjectName("tokenCount_2")
|
||||
self.verticalLayout_4.addWidget(self.tokenCount_2)
|
||||
self.horizontalLayout_2.addLayout(self.verticalLayout_4)
|
||||
self.horizontalLayout_4.addWidget(self.groupBox_2)
|
||||
self.verticalLayout_5.addLayout(self.horizontalLayout_4)
|
||||
self.buttonBox = QtWidgets.QDialogButtonBox(parent=tieDialog)
|
||||
self.buttonBox.setOrientation(QtCore.Qt.Orientation.Horizontal)
|
||||
self.buttonBox.setStandardButtons(
|
||||
QtWidgets.QDialogButtonBox.StandardButton.Cancel
|
||||
| QtWidgets.QDialogButtonBox.StandardButton.Ok
|
||||
)
|
||||
self.buttonBox.setStandardButtons(QtWidgets.QDialogButtonBox.StandardButton.Cancel|QtWidgets.QDialogButtonBox.StandardButton.Ok)
|
||||
self.buttonBox.setObjectName("buttonBox")
|
||||
self.gridLayout.addWidget(self.buttonBox, 2, 0, 1, 1)
|
||||
self.verticalLayout_5.addWidget(self.buttonBox)
|
||||
|
||||
self.retranslateUi(tieDialog)
|
||||
self.buttonBox.accepted.connect(tieDialog.accept) # type: ignore
|
||||
self.buttonBox.rejected.connect(tieDialog.reject) # type: ignore
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@
|
|||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>481</width>
|
||||
<height>155</height>
|
||||
<width>477</width>
|
||||
<height>174</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
|
|
@ -20,8 +20,8 @@
|
|||
<iconset>
|
||||
<normaloff>../resources/warchron_logo.png</normaloff>../resources/warchron_logo.png</iconset>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="0" column="0">
|
||||
<layout class="QVBoxLayout" name="verticalLayout_5">
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_3">
|
||||
<item>
|
||||
<widget class="QLabel" name="tieContext">
|
||||
|
|
@ -52,25 +52,101 @@
|
|||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QScrollArea" name="playersScrollArea">
|
||||
<property name="widgetResizable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<widget class="QWidget" name="scrollAreaWidgetContents">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>461</width>
|
||||
<height>78</height>
|
||||
</rect>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="playersGridLayout"/>
|
||||
</widget>
|
||||
</widget>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout_4">
|
||||
<item>
|
||||
<widget class="QGroupBox" name="groupBox_1">
|
||||
<property name="title">
|
||||
<string>Player 1</string>
|
||||
</property>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QLabel" name="label_5">
|
||||
<property name="text">
|
||||
<string>Spend token</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<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 row="2" column="0">
|
||||
<item>
|
||||
<widget class="QDialogButtonBox" name="buttonBox">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
|
|
|
|||
|
|
@ -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,33 +464,19 @@ class View(QtWidgets.QMainWindow, Ui_MainWindow):
|
|||
table.resizeColumnsToContents()
|
||||
|
||||
def display_campaign_participants(
|
||||
self,
|
||||
participants: List[CampaignParticipantScoreDTO],
|
||||
objectives: List[ObjectiveDTO],
|
||||
self, participants: List[CampaignParticipantDTO]
|
||||
) -> 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)
|
||||
VP_item = QtWidgets.QTableWidgetItem(str(part.victory_points))
|
||||
name_item.setData(Qt.ItemDataRole.UserRole, part.campaign_participant_id)
|
||||
name_item.setData(Qt.ItemDataRole.UserRole, part.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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue