From a3b9f5a943aa2085f14ce77e5ae8d83230eee981 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20R=C3=A9aux?= Date: Mon, 16 Mar 2026 17:27:51 +0100 Subject: [PATCH 1/2] fix compute score of war participants absent from campaign --- src/warchron/model/score_service.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/warchron/model/score_service.py b/src/warchron/model/score_service.py index f061729..40b0891 100644 --- a/src/warchron/model/score_service.py +++ b/src/warchron/model/score_service.py @@ -44,11 +44,17 @@ class ScoreService: ) -> Dict[str, ParticipantScore]: from warchron.model.result_checker import ResultChecker + if context_type == ContextType.CAMPAIGN: + camp = war.get_campaign(context_id) + camp_pids = camp.get_all_campaign_participants_ids() + participant_ids = [(camp.campaign_to_war_part_id(pid)) for pid in camp_pids] + elif context_type == ContextType.WAR: + participant_ids = war.get_all_war_participants_ids() scores = { pid: ParticipantScore( narrative_points={obj_id: 0 for obj_id in war.objectives} ) - for pid in war.participants + for pid in participant_ids } battles = ScoreService._get_battles_for_context(war, context_type, context_id) for battle in battles: From 42ad708e77fc07e8e574a0c8b14066d16f2010c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20R=C3=A9aux?= Date: Tue, 17 Mar 2026 11:16:47 +0100 Subject: [PATCH 2/2] fix choice tiebreak loop and cancel tiebreak lost tokens --- .../controller/campaign_controller.py | 6 +- src/warchron/controller/closure_workflow.py | 28 +-- src/warchron/controller/round_controller.py | 16 +- src/warchron/controller/war_controller.py | 6 +- src/warchron/model/battle.py | 4 +- src/warchron/model/campaign.py | 10 +- src/warchron/model/model.py | 4 +- src/warchron/model/pairing.py | 90 ++++++--- src/warchron/model/round.py | 34 +++- src/warchron/model/tie_manager.py | 174 +++++++++++------- src/warchron/model/war.py | 61 +++++- src/warchron/model/war_event.py | 24 ++- test_data/example.json | 5 + 13 files changed, 333 insertions(+), 129 deletions(-) diff --git a/src/warchron/controller/campaign_controller.py b/src/warchron/controller/campaign_controller.py index 2477378..97586b2 100644 --- a/src/warchron/controller/campaign_controller.py +++ b/src/warchron/controller/campaign_controller.py @@ -167,7 +167,7 @@ class CampaignController: def resolve_ties( self, war: War, contexts: List[TieContext] - ) -> Dict[tuple[str, str, int | None], Dict[str, bool]]: + ) -> Dict[tuple[str, str, int | None, str | None, str | None], Dict[str, bool]]: bids_map = {} for ctx in contexts: active = TieResolver.get_active_participants(war, ctx, ctx.participants) @@ -180,7 +180,7 @@ class CampaignController: parent=self.app.view, players=players, counters=counters, - context_type=ContextType.CAMPAIGN, + context_type=ctx.context_type, context_id=ctx.context_id, context_name=None, ) @@ -192,7 +192,7 @@ class CampaignController: counters=counters, context_type=ctx.context_type, context_id=ctx.context_id, - context_name=objective.name, + context_name=f"Objective tie: {objective.name}", ) if not dialog.exec(): TieResolver.cancel_tie_break(war, ctx) diff --git a/src/warchron/controller/closure_workflow.py b/src/warchron/controller/closure_workflow.py index ba02870..f824d26 100644 --- a/src/warchron/controller/closure_workflow.py +++ b/src/warchron/controller/closure_workflow.py @@ -1,4 +1,5 @@ from typing import TYPE_CHECKING +from uuid import uuid4 if TYPE_CHECKING: from warchron.controller.app_controller import AppController @@ -25,8 +26,9 @@ class RoundClosureWorkflow(ClosureWorkflow): bids_map = self.app.rounds.resolve_ties(war, ties) for tie in ties: bids = bids_map[tie.key()] - TieResolver.apply_bids(war, tie, bids) - TieResolver.resolve_tie_state(war, tie, bids) + tie_id = TieResolver.find_active_tie_id(war, tie) or str(uuid4()) + TieResolver.apply_bids(war, tie, tie_id, bids) + TieResolver.resolve_tie_state(war, tie, tie_id, bids) ties = TieResolver.find_battle_ties(war, round.id) for battle in round.battles.values(): ClosureService.apply_battle_outcomes(war, campaign, battle) @@ -42,8 +44,9 @@ class CampaignClosureWorkflow(ClosureWorkflow): bids_map = self.app.campaigns.resolve_ties(war, ties) for tie in ties: bids = bids_map[tie.key()] - TieResolver.apply_bids(war, tie, bids) - TieResolver.resolve_tie_state(war, tie, bids) + tie_id = TieResolver.find_active_tie_id(war, tie) or str(uuid4()) + TieResolver.apply_bids(war, tie, tie_id, bids) + TieResolver.resolve_tie_state(war, tie, tie_id, bids) ties = TieResolver.find_campaign_ties(war, campaign.id) for obj in war.get_objectives_used_as_maj_or_min(): objective_id = obj.id @@ -56,8 +59,9 @@ class CampaignClosureWorkflow(ClosureWorkflow): bids_map = self.app.campaigns.resolve_ties(war, ties) for tie in ties: bids = bids_map[tie.key()] - TieResolver.apply_bids(war, tie, bids) - TieResolver.resolve_tie_state(war, tie, bids) + tie_id = TieResolver.find_active_tie_id(war, tie) or str(uuid4()) + TieResolver.apply_bids(war, tie, tie_id, bids) + TieResolver.resolve_tie_state(war, tie, tie_id, bids) ties = TieResolver.find_campaign_objective_ties( war, campaign.id, @@ -75,8 +79,9 @@ class WarClosureWorkflow(ClosureWorkflow): bids_map = self.app.wars.resolve_ties(war, ties) for tie in ties: bids = bids_map[tie.key()] - TieResolver.apply_bids(war, tie, bids) - TieResolver.resolve_tie_state(war, tie, bids) + tie_id = TieResolver.find_active_tie_id(war, tie) or str(uuid4()) + TieResolver.apply_bids(war, tie, tie_id, bids) + TieResolver.resolve_tie_state(war, tie, tie_id, bids) ties = TieResolver.find_war_ties(war) for obj in war.get_objectives_used_as_maj_or_min(): objective_id = obj.id @@ -88,8 +93,9 @@ class WarClosureWorkflow(ClosureWorkflow): bids_map = self.app.wars.resolve_ties(war, ties) for tie in ties: bids = bids_map[tie.key()] - TieResolver.apply_bids(war, tie, bids) - TieResolver.resolve_tie_state(war, tie, bids) + tie_id = TieResolver.find_active_tie_id(war, tie) or str(uuid4()) + TieResolver.apply_bids(war, tie, tie_id, bids) + TieResolver.resolve_tie_state(war, tie, tie_id, bids) ties = TieResolver.find_war_objective_ties( war, objective_id, @@ -103,7 +109,7 @@ class RoundPairingWorkflow: self.app = controller def start(self, war: War, round: Round) -> None: - Pairing.check_round_pairable(round) + Pairing.check_round_pairable(war, round) Pairing.assign_battles_to_participants( war, round, diff --git a/src/warchron/controller/round_controller.py b/src/warchron/controller/round_controller.py index aeb86a2..3b7f5e6 100644 --- a/src/warchron/controller/round_controller.py +++ b/src/warchron/controller/round_controller.py @@ -72,6 +72,7 @@ class RoundController: comment=choice.comment, ) ) + # TODO display allocated sectors and used token self.app.view.display_round_choices(choices_for_display) battles_for_display: List[BattleDTO] = [] for sect in sectors: @@ -89,6 +90,7 @@ class RoundController: player_1_name = self.app.model.get_participant_name( camp_part.war_participant_id ) + p1_id = battle.player_1_id else: player_1_name = "" if battle.player_2_id: @@ -96,6 +98,7 @@ class RoundController: player_2_name = self.app.model.get_participant_name( camp_part.war_participant_id ) + p2_id = battle.player_2_id else: player_2_name = "" if battle.winner_id: @@ -112,7 +115,11 @@ class RoundController: if battle.is_draw(): p1_icon = Icons.get(IconName.DRAW) p2_icon = Icons.get(IconName.DRAW) - context = TieContext(ContextType.BATTLE, battle.sector_id) + context = TieContext( + ContextType.BATTLE, + battle.sector_id, + [p1_id, p2_id], + ) if TieResolver.was_tie_broken_by_tokens(war, context): effective_winner = ResultChecker.get_effective_winner_id( war, ContextType.BATTLE, battle.sector_id, None @@ -197,7 +204,7 @@ class RoundController: str(e), ) for bat in rnd.battles.values(): - bat.cleanup_battle_players() + bat.clear_battle_players() return except DomainError as e: QMessageBox.warning( @@ -225,7 +232,7 @@ class RoundController: def resolve_ties( self, war: War, contexts: List[TieContext] - ) -> Dict[tuple[str, str, int | None], Dict[str, bool]]: + ) -> Dict[tuple[str, str, int | None, str | None, str | None], Dict[str, bool]]: bids_map = {} for ctx in contexts: players = [ @@ -236,11 +243,12 @@ class RoundController: for pid in ctx.participants ] counters = [war.get_influence_tokens(pid) for pid in ctx.participants] + # TODO display sector name for BATTLE or CHOICE dialog = TieDialog( parent=self.app.view, players=players, counters=counters, - context_type=ContextType.BATTLE, + context_type=ctx.context_type, context_id=ctx.context_id, ) if not dialog.exec(): diff --git a/src/warchron/controller/war_controller.py b/src/warchron/controller/war_controller.py index 8f065ad..2c000e9 100644 --- a/src/warchron/controller/war_controller.py +++ b/src/warchron/controller/war_controller.py @@ -58,7 +58,7 @@ class WarController: ] scores = ScoreService.compute_scores(war, ContextType.WAR, war.id) rows: List[WarParticipantScoreDTO] = [] - vp_icon_map: dict[str, QIcon] = {} + vp_icon_map: Dict[str, QIcon] = {} objective_icon_maps: Dict[str, Dict[str, QIcon]] = {} if war.is_over: vp_icon_map = RankingIcon.compute_icons( @@ -157,7 +157,7 @@ class WarController: def resolve_ties( self, war: War, contexts: List[TieContext] - ) -> Dict[tuple[str, str, int | None], Dict[str, bool]]: + ) -> Dict[tuple[str, str, int | None, str | None, str | None], Dict[str, bool]]: bids_map = {} for ctx in contexts: active = TieResolver.get_active_participants( @@ -174,7 +174,7 @@ class WarController: parent=self.app.view, players=players, counters=counters, - context_type=ContextType.WAR, + context_type=ctx.context_type, context_id=ctx.context_id, context_name=None, ) diff --git a/src/warchron/model/battle.py b/src/warchron/model/battle.py index a34a3b3..02a9e90 100644 --- a/src/warchron/model/battle.py +++ b/src/warchron/model/battle.py @@ -57,7 +57,7 @@ class Battle: return False def get_available_places(self) -> List[str]: - places: list[str] = [] + places: List[str] = [] if self.player_1_id is None: places.append("player_1") if self.player_2_id is None: @@ -73,7 +73,7 @@ class Battle: return raise DomainError("Battle has no available places") - def cleanup_battle_players(self) -> None: + def clear_battle_players(self) -> None: self.player_1_id = None self.player_2_id = None diff --git a/src/warchron/model/campaign.py b/src/warchron/model/campaign.py index 9f88f5b..4efc374 100644 --- a/src/warchron/model/campaign.py +++ b/src/warchron/model/campaign.py @@ -1,7 +1,9 @@ from __future__ import annotations from uuid import uuid4 -from typing import Any, Dict, List, Set +from typing import Any, Dict, List, Set, TYPE_CHECKING +if TYPE_CHECKING: + from warchron.model.war import War from warchron.model.exception import ForbiddenOperation, RequiresConfirmation from warchron.model.campaign_participant import CampaignParticipant from warchron.model.sector import Sector @@ -19,6 +21,7 @@ class Campaign: self.sectors: Dict[str, Sector] = {} self.rounds: List[Round] = [] self.is_over = False + self._war: War | None = None # private link def set_id(self, new_id: str) -> None: self.id = new_id @@ -60,8 +63,8 @@ class Campaign: # Campaign participant methods - def get_all_campaign_participants_ids(self) -> set[str]: - return set(self.participants.keys()) + def get_all_campaign_participants_ids(self) -> List[str]: + return list(self.participants.keys()) def has_participant(self, participant_id: str) -> bool: return participant_id in self.participants @@ -351,6 +354,7 @@ class Campaign: if self.is_over: raise ForbiddenOperation("Can't add round in a closed campaign.") round = Round() + round._campaign = self self.rounds.append(round) return round diff --git a/src/warchron/model/model.py b/src/warchron/model/model.py index 65a4a7b..ca6dc23 100644 --- a/src/warchron/model/model.py +++ b/src/warchron/model/model.py @@ -360,8 +360,8 @@ class Model: ) def remove_sector(self, sector_id: str) -> None: - camp = self.get_campaign_by_sector(sector_id) - camp.remove_sector(sector_id) + war = self.get_war_by_sector(sector_id) + war.remove_sector(sector_id) # Campaign participant methods diff --git a/src/warchron/model/pairing.py b/src/warchron/model/pairing.py index 4ec0641..5936e36 100644 --- a/src/warchron/model/pairing.py +++ b/src/warchron/model/pairing.py @@ -1,5 +1,7 @@ from __future__ import annotations from typing import Dict, List, Callable, Tuple +from uuid import uuid4 + import random from warchron.constants import ContextType, ScoreKind @@ -18,7 +20,7 @@ from warchron.model.score_service import ParticipantScore ResolveTiesCallback = Callable[ ["War", List["TieContext"]], - Dict[Tuple[str, str, int | None], Dict[str, bool]], + Dict[Tuple[str, str, int | None, str | None, str | None], Dict[str, bool]], ] @@ -26,6 +28,7 @@ class Pairing: @staticmethod def check_round_pairable( + war: War, round: Round, ) -> None: if round.is_over: @@ -48,8 +51,9 @@ class Pairing: def cleanup() -> None: for bat in round.battles.values(): - bat.cleanup_battle_players() - # FIXME cancel TieResolved + TokenSpent + bat.clear_battle_players() + bat.set_winner(None) + war.revert_choice_ties(round.id) if any( bat.player_1_id is not None or bat.player_2_id is not None @@ -58,6 +62,7 @@ class Pairing: raise RequiresConfirmation( "Battle(s) already have player(s) assigned for this round.\n" "Battle players will be cleared.\n" + "Choice tokens and tie-breaks will be deleted.\n" "Do you want to continue?", action=cleanup, ) @@ -90,20 +95,20 @@ class Pairing: campaign.war_to_campaign_part_id(pid) for pid in group ] Pairing._run_phase( - war, - round, - remaining, - sector_to_battle, - resolve_ties_callback, + war=war, + round=round, + remaining=remaining, + sector_to_battle=sector_to_battle, + resolve_ties_callback=resolve_ties_callback, use_priority=True, score_value=score_value, ) Pairing._run_phase( - war, - round, - remaining, - sector_to_battle, - resolve_ties_callback, + war=war, + round=round, + remaining=remaining, + sector_to_battle=sector_to_battle, + resolve_ties_callback=resolve_ties_callback, use_priority=False, score_value=score_value, ) @@ -133,13 +138,13 @@ class Pairing: if places <= 0: continue winners = Pairing._resolve_sector_allocation( - war, - round, - sector_id, - participants, - places, - resolve_ties_callback, - score_value, + war=war, + round=round, + sector_id=sector_id, + participants=participants, + places=places, + resolve_ties_callback=resolve_ties_callback, + score_value=score_value, ) for pid in winners: battle.assign_participant(pid) @@ -187,26 +192,59 @@ class Pairing: participants=[ campaign.campaign_to_war_part_id(pid) for pid in participants ], + score_value=score_value, score_kind=ScoreKind.VP, sector_id=sector_id, ) # ---- resolve tie loop ---- + tie_id = TieResolver.find_active_tie_id(war, context) or str(uuid4()) while not TieResolver.is_tie_resolved(war, context): - if not TieResolver.can_tie_be_resolved(war, context, context.participants): + active = TieResolver.get_active_participants( + war, context, context.participants + ) + if len(active) <= 1: + break + current_context = TieContext( + context_type=context.context_type, + context_id=context.context_id, + participants=active, + score_value=context.score_value, + score_kind=context.score_kind, + sector_id=context.sector_id, + ) + if not TieResolver.can_tie_be_resolved( + war, context, current_context.participants + ): war.events.append( TieResolved( None, context.context_type, context.context_id, + participants, + tie_id=tie_id, score_value=score_value, sector_id=sector_id, ) ) break - bids_map = resolve_ties_callback(war, [context]) - bids = bids_map[context.key()] - TieResolver.apply_bids(war, context, bids) - TieResolver.resolve_tie_state(war, context, bids) + bids_map = resolve_ties_callback(war, [current_context]) + bids = bids_map[current_context.key()] + # confirmed draw if current bids are 0 + if bids is not None and not any(bids.values()): + war.events.append( + TieResolved( + None, + context.context_type, + context.context_id, + participants=context.participants, + tie_id=tie_id, + score_value=context.score_value, + objective_id=context.objective_id, + ) + ) + break + TieResolver.apply_bids(war, context, tie_id, bids) + TieResolver.resolve_tie_state(war, context, tie_id, bids) ranked_groups = TieResolver.rank_by_tokens( war, context, @@ -215,6 +253,8 @@ class Pairing: ordered: List[str] = [] for group in ranked_groups: shuffled_group = list(group) + # TODO improve tie break with history parsing + # TODO avoid rematch random.shuffle(shuffled_group) ordered.extend( campaign.war_to_campaign_part_id(pid) for pid in shuffled_group diff --git a/src/warchron/model/round.py b/src/warchron/model/round.py index df9c927..f7d8039 100644 --- a/src/warchron/model/round.py +++ b/src/warchron/model/round.py @@ -1,7 +1,9 @@ from __future__ import annotations from uuid import uuid4 -from typing import Any, Dict, List +from typing import Any, Dict, List, TYPE_CHECKING +if TYPE_CHECKING: + from warchron.model.campaign import Campaign from warchron.model.exception import ForbiddenOperation from warchron.model.choice import Choice from warchron.model.battle import Battle @@ -13,6 +15,7 @@ class Round: self.choices: Dict[str, Choice] = {} self.battles: Dict[str, Battle] = {} self.is_over: bool = False + self._campaign: Campaign | None = None # private link def set_id(self, new_id: str) -> None: self.id = new_id @@ -60,6 +63,7 @@ class Round: def create_choice(self, participant_id: str) -> Choice: if self.is_over: + # TODO catch me if you can raise ForbiddenOperation("Can't create choice in a closed round.") if participant_id not in self.choices: choice = Choice( @@ -78,23 +82,38 @@ class Round: comment: str | None, ) -> None: if self.is_over: + # TODO catch me if you can raise ForbiddenOperation("Can't update choice in a closed round.") + # TODO prevent if battles already assigned choice = self.get_choice(participant_id) if choice: choice.set_priority(priority_sector_id) choice.set_secondary(secondary_sector_id) choice.set_comment(comment) + # FIXME remove corresponding InfluenceSpent and TieResolved def clear_sector_references(self, sector_id: str) -> None: for choice in self.choices.values(): + trigger_revert_ties = False if choice.priority_sector_id == sector_id: choice.priority_sector_id = None + trigger_revert_ties = True if choice.secondary_sector_id == sector_id: choice.secondary_sector_id = None + trigger_revert_ties = True + if trigger_revert_ties: + if self._campaign and self._campaign._war: + self._campaign._war.revert_choice_ties(self.id, sector_id=sector_id) def remove_choice(self, participant_id: str) -> None: if self.is_over: + # TODO catch me if you can (inner) raise ForbiddenOperation("Can't remove choice in a closed round.") + # TODO prevent if battles already assigned + if self._campaign and self._campaign._war: + self._campaign._war.revert_choice_ties( + self.id, participants=[participant_id] + ) del self.choices[participant_id] # Battle methods @@ -126,6 +145,7 @@ class Round: def create_battle(self, sector_id: str) -> Battle: if self.is_over: + # TODO catch me if you can raise ForbiddenOperation("Can't create battle in a closed round.") if sector_id not in self.battles: battle = Battle(sector_id=sector_id, player_1_id=None, player_2_id=None) @@ -143,8 +163,10 @@ class Round: comment: str | None, ) -> None: if self.is_over: + # TODO catch me if you can raise ForbiddenOperation("Can't update battle in a closed round.") bat = self.get_battle(sector_id) + # TODO require confirmation if there was choice tie to clear it if bat: bat.set_player_1(player_1_id) bat.set_player_2(player_2_id) @@ -155,17 +177,27 @@ class Round: def clear_participant_references(self, participant_id: str) -> None: for battle in self.battles.values(): + trigger_revert_ties = False if battle.player_1_id == participant_id: battle.player_1_id = None + trigger_revert_ties = True if battle.player_2_id == participant_id: battle.player_2_id = None + trigger_revert_ties = True if battle.winner_id == participant_id: battle.winner_id = None + if trigger_revert_ties: + if self._campaign and self._campaign._war: + self._campaign._war.revert_battle_ties(battle.sector_id) def remove_battle(self, sector_id: str) -> None: if self.is_over: + # TODO catch me if you can raise ForbiddenOperation("Can't remove battle in a closed round.") bat = self.battles[sector_id] if bat and bat.is_finished(): + # TODO catch me if you can raise ForbiddenOperation("Can't remove finished battle.") + if self._campaign and self._campaign._war: + self._campaign._war.revert_battle_ties(sector_id) del self.battles[sector_id] diff --git a/src/warchron/model/tie_manager.py b/src/warchron/model/tie_manager.py index 47ca576..4694f57 100644 --- a/src/warchron/model/tie_manager.py +++ b/src/warchron/model/tie_manager.py @@ -1,5 +1,5 @@ -from typing import List, Dict, DefaultDict -from dataclasses import dataclass, field +from typing import List, Dict, DefaultDict, Tuple +from dataclasses import dataclass from collections import defaultdict from warchron.constants import ContextType, ScoreKind @@ -13,41 +13,70 @@ from warchron.model.score_service import ScoreService, ParticipantScore class TieContext: context_type: ContextType context_id: str - participants: List[str] = field(default_factory=list) # war_participant_ids + participants: List[str] score_value: int | None = None score_kind: ScoreKind | None = None objective_id: str | None = None sector_id: str | None = None - def key(self) -> tuple[str, str, int | None]: - return (self.context_type, self.context_id, self.score_value) + def key(self) -> Tuple[str, str, int | None, str | None, str | None]: + return ( + self.context_type, + self.context_id, + self.score_value, + self.objective_id, + self.sector_id, + ) class TieResolver: + @staticmethod + def find_active_tie_id( + war: War, + context: TieContext, + ) -> str | None: + for ev in reversed(war.events): + if ( + isinstance(ev, InfluenceSpent) + and ev.context_type == context.context_type + and ev.context_id == context.context_id + and ev.objective_id == context.objective_id + and ev.sector_id == context.sector_id + and ev.participant_id in context.participants + ): + return ev.tie_id + return None + @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) ties = [] for battle in round.battles.values(): - if not battle.is_draw(): - continue - context: TieContext = TieContext( - ContextType.BATTLE, - battle.sector_id, - ) - if TieResolver.is_tie_resolved(war, context): - continue if campaign is None: raise DomainError("No campaign for this battle tie") if battle.player_1_id is None or battle.player_2_id is None: raise DomainError("Missing player(s) in this battle context.") p1_id = campaign.campaign_to_war_part_id(battle.player_1_id) p2_id = campaign.campaign_to_war_part_id(battle.player_2_id) + if not battle.is_draw(): + continue + context: TieContext = TieContext( + ContextType.BATTLE, battle.sector_id, [p1_id, p2_id] + ) + if TieResolver.is_tie_resolved(war, context): + continue + tie_id = TieResolver.find_active_tie_id(war, context) if not TieResolver.can_tie_be_resolved(war, context, [p1_id, p2_id]): war.events.append( - TieResolved(None, ContextType.BATTLE, battle.sector_id) + TieResolved( + participant_id=None, + context_type=ContextType.BATTLE, + context_id=battle.sector_id, + participants=[p1_id, p2_id], + tie_id=tie_id, + ) ) continue ties.append( @@ -80,13 +109,16 @@ class TieResolver: ) if TieResolver.is_tie_resolved(war, context): continue + tie_id = TieResolver.find_active_tie_id(war, context) if not TieResolver.can_tie_be_resolved(war, context, participants): war.events.append( TieResolved( - None, - ContextType.CAMPAIGN, - campaign_id, - score_value, + participant_id=None, + context_type=ContextType.CAMPAIGN, + context_id=campaign_id, + participants=participants, + tie_id=tie_id, + score_value=score_value, ) ) continue @@ -103,9 +135,7 @@ class TieResolver: @staticmethod def find_campaign_objective_ties( - war: War, - campaign_id: str, - objective_id: str, + war: War, campaign_id: str, objective_id: str ) -> List[TieContext]: scores = ScoreService.compute_scores( war, @@ -131,6 +161,7 @@ class TieResolver: ) if TieResolver.is_tie_resolved(war, context): continue + tie_id = TieResolver.find_active_tie_id(war, context) if not TieResolver.can_tie_be_resolved( war, context, @@ -138,7 +169,13 @@ class TieResolver: ): war.events.append( TieResolved( - None, ContextType.CAMPAIGN, context_id, np_value, objective_id + participant_id=None, + context_type=ContextType.CAMPAIGN, + context_id=context_id, + participants=participants, + tie_id=tie_id, + score_value=np_value, + objective_id=objective_id, ) ) continue @@ -181,9 +218,17 @@ class TieResolver: ) if TieResolver.is_tie_resolved(war, context): continue + tie_id = TieResolver.find_active_tie_id(war, context) if not TieResolver.can_tie_be_resolved(war, context, group): war.events.append( - TieResolved(None, ContextType.WAR, war.id, score_value) + TieResolved( + participant_id=None, + context_type=ContextType.WAR, + context_id=war.id, + participants=group, + tie_id=tie_id, + score_value=score_value, + ) ) continue ties.append( @@ -198,10 +243,7 @@ class TieResolver: return ties @staticmethod - def find_war_objective_ties( - war: War, - objective_id: str, - ) -> List[TieContext]: + def find_war_objective_ties(war: War, objective_id: str) -> List[TieContext]: from warchron.model.result_checker import ResultChecker scores = ScoreService.compute_scores( @@ -235,13 +277,22 @@ class TieResolver: context, ): continue + tie_id = TieResolver.find_active_tie_id(war, context) if not TieResolver.can_tie_be_resolved( war, context, group, ): war.events.append( - TieResolved(None, ContextType.WAR, war.id, np_value, objective_id) + TieResolved( + participant_id=None, + context_type=ContextType.WAR, + context_id=war.id, + participants=group, + tie_id=tie_id, + score_value=np_value, + objective_id=objective_id, + ) ) continue ties.append( @@ -260,6 +311,7 @@ class TieResolver: def apply_bids( war: War, context: TieContext, + tie_id: str, bids: Dict[str, bool], # war_participant_id -> spend? ) -> None: for war_part_id, spend in bids.items(): @@ -273,6 +325,7 @@ class TieResolver: amount=1, context_type=context.context_type, context_id=context.context_id, + tie_id=tie_id, objective_id=context.objective_id, ) ) @@ -287,12 +340,7 @@ class TieResolver: for ev in war.events if not ( ( - isinstance(ev, InfluenceSpent) - and ev.context_type == context.context_type - and ev.context_id == context.context_id - ) - or ( - isinstance(ev, TieResolved) + (isinstance(ev, InfluenceSpent) or isinstance(ev, TieResolved)) and ev.context_type == context.context_type and ev.context_id == context.context_id ) @@ -356,25 +404,9 @@ class TieResolver: def resolve_tie_state( war: War, context: TieContext, - bids: dict[str, bool] | None = None, + tie_id: str, + bids: Dict[str, bool] | None = None, ) -> None: - active = TieResolver.get_active_participants( - war, - context, - context.participants, - ) - # confirmed draw if none had bid - if not active: - war.events.append( - TieResolved( - None, - context.context_type, - context.context_id, - score_value=context.score_value, - objective_id=context.objective_id, - ) - ) - return # confirmed draw if current bids are 0 if bids is not None and not any(bids.values()): war.events.append( @@ -382,12 +414,13 @@ class TieResolver: None, context.context_type, context.context_id, + participants=context.participants, + tie_id=tie_id, score_value=context.score_value, objective_id=context.objective_id, ) ) return - # else rank_by_tokens groups = TieResolver.rank_by_tokens(war, context, context.participants) if len(groups[0]) == 1: war.events.append( @@ -395,8 +428,10 @@ class TieResolver: groups[0][0], context.context_type, context.context_id, - context.score_value, - context.objective_id, + participants=context.participants, + tie_id=tie_id, + score_value=context.score_value, + objective_id=context.objective_id, ) ) return @@ -427,11 +462,24 @@ class TieResolver: @staticmethod def is_tie_resolved(war: War, context: TieContext) -> bool: - return any( - isinstance(ev, TieResolved) - and ev.context_type == context.context_type - and ev.context_id == context.context_id - and ev.score_value == context.score_value - and ev.objective_id == context.objective_id - for ev in war.events - ) + for ev in war.events: + if not isinstance(ev, TieResolved): + continue + if ev.context_type != context.context_type: + continue + if ev.context_id != context.context_id: + continue + if ( + context.score_value is not None + and ev.score_value != context.score_value + ): + continue + if ( + context.objective_id is not None + and ev.objective_id != context.objective_id + ): + continue + if context.sector_id is not None and ev.sector_id != context.sector_id: + continue + return True + return False diff --git a/src/warchron/model/war.py b/src/warchron/model/war.py index 62767e4..b7bc964 100644 --- a/src/warchron/model/war.py +++ b/src/warchron/model/war.py @@ -1,7 +1,7 @@ from __future__ import annotations from uuid import uuid4 from datetime import datetime -from typing import Any, Dict, List +from typing import Any, Dict, List, Set from warchron.constants import ContextType from warchron.model.war_event import ( @@ -62,7 +62,7 @@ class War: if self.is_over: raise ForbiddenOperation("Can't set influence token of a closed war.") - def cleanup_token_and_tie() -> None: + def remove_token_and_draw_tie() -> None: new_events: List[WarEvent] = [] for ev in self.events: if isinstance(ev, (InfluenceSpent, InfluenceGained)): @@ -73,11 +73,10 @@ class War: self.events = new_events self.influence_token = new_state - def reset_tie_break() -> None: + def remove_tiebreak_and_not_over() -> None: new_events: List[WarEvent] = [] for ev in self.events: if isinstance(ev, (TieResolved)): - # FIXME cancel TieResolved + TokenSpent if ev.context_type == ContextType.BATTLE: battle = self.get_battle(ev.context_id) campaign = self.get_campaign_by_sector(battle.sector_id) @@ -86,6 +85,7 @@ class War: elif ev.context_type == ContextType.CAMPAIGN: campaign = self.get_campaign(ev.context_id) campaign.is_over = False + # nothing specific to do with CHOICE (can retrigger pairing) else: new_events.append(ev) self.events = new_events @@ -104,7 +104,7 @@ class War: "Some influence tokens already exist in this war.\n" "All tokens will be deleted and tie-breaks will become draw.\n" "Do you want to continue?", - action=cleanup_token_and_tie, + action=remove_token_and_draw_tie, ) if new_state is True: has_tie_resolved = any(isinstance(ev, TieResolved) for ev in self.events) @@ -120,7 +120,7 @@ class War: "Some influence tokens and draws exist in this war.\n" "All influence outcomes and tie-breaks will be reset.\n" "Do you want to continue?", - action=reset_tie_break, + action=remove_tiebreak_and_not_over, ) def set_state(self, new_state: bool) -> None: @@ -216,8 +216,8 @@ class War: # War participant methods - def get_all_war_participants_ids(self) -> set[str]: - return set(self.participants.keys()) + def get_all_war_participants_ids(self) -> List[str]: + return list(self.participants.keys()) def has_participant(self, participant_id: str) -> bool: return participant_id in self.participants @@ -288,6 +288,7 @@ class War: if month is None: month = self.get_default_campaign_values()["month"] campaign = Campaign(name, month) + campaign._war = self self.campaigns.append(campaign) return campaign @@ -550,3 +551,47 @@ class War: if isinstance(e, InfluenceSpent) and e.participant_id == participant_id ) return gained - spent + + def get_events_by_ties_session(self, tie_id: str) -> List[WarEvent]: + return [ev for ev in self.events if ev.tie_id == tie_id] + + def remove_ties_session(self, tie_id: str) -> None: + self.events = [ev for ev in self.events if ev.tie_id != tie_id] + + def revert_choice_ties( + self, + round_id: str, + *, + sector_id: str | None = None, + participants: List[str] | None = None, + ) -> None: + removed_ties: Set[str] = set() + for ev in self.events: + if ( + isinstance(ev, TieResolved) + and ev.context_type == ContextType.CHOICE + and ev.context_id == round_id + ): + if ( + sector_id is None + or ev.sector_id == sector_id + or participants is None + or any(p in ev.participants for p in participants) + ): + if ev.tie_id: + removed_ties.add(ev.tie_id) + self.events = [ev for ev in self.events if ev.tie_id not in removed_ties] + + def revert_battle_ties(self, sector_id: str) -> None: + removed_ties = { + ev.tie_id + for ev in self.events + if isinstance(ev, TieResolved) + and ev.context_type == ContextType.BATTLE + and ev.context_id == sector_id + and ev.tie_id + } + self.events = [ev for ev in self.events if ev.tie_id not in removed_ties] + + def revert_tie(self, tie_id: str) -> None: + self.events = [ev for ev in self.events if ev.tie_id != tie_id] diff --git a/src/warchron/model/war_event.py b/src/warchron/model/war_event.py index a717241..4d2ae93 100644 --- a/src/warchron/model/war_event.py +++ b/src/warchron/model/war_event.py @@ -1,5 +1,5 @@ from __future__ import annotations -from typing import Dict, Any, TypeVar, Type, cast +from typing import Dict, Any, TypeVar, Type, cast, List from datetime import datetime from uuid import uuid4 @@ -22,12 +22,14 @@ class WarEvent: participant_id: str | None, context_type: str, context_id: str, + tie_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() + self.tie_id = tie_id def set_id(self, new_id: str) -> None: self.id = new_id @@ -46,6 +48,7 @@ class WarEvent: "context_type": self.context_type, "context_id": self.context_id, "timestamp": self.timestamp.isoformat(), + "tie_id": self.tie_id, } @classmethod @@ -55,6 +58,7 @@ class WarEvent: ev.context_type = data["context_type"] ev.context_id = data["context_id"] ev.timestamp = datetime.fromisoformat(data["timestamp"]) + ev.tie_id = data.get("tie_id") return ev @staticmethod @@ -72,14 +76,17 @@ class TieResolved(WarEvent): def __init__( self, - participant_id: str | None, + participant_id: str | None, # winner_id or None if confirmed draw context_type: str, context_id: str, + participants: List[str], + tie_id: str | None = None, # None if draw without tie-break score_value: int | None = None, objective_id: str | None = None, sector_id: str | None = None, ): - super().__init__(participant_id, context_type, context_id) + super().__init__(participant_id, context_type, context_id, tie_id) + self.participants = participants self.score_value = score_value self.objective_id = objective_id self.sector_id = sector_id @@ -88,6 +95,7 @@ class TieResolved(WarEvent): d = super().toDict() d.update( { + "participants": self.participants, "score_value": self.score_value or None, "objective_id": self.objective_id or None, "sector_id": self.sector_id or None, @@ -101,6 +109,8 @@ class TieResolved(WarEvent): JsonHelper.none_if_empty(data["participant_id"]), data["context_type"], data["context_id"], + data["participants"], + data["tie_id"], JsonHelper.none_if_empty(data["score_value"]), JsonHelper.none_if_empty(data["objective_id"]), JsonHelper.none_if_empty(data["sector_id"]), @@ -156,11 +166,14 @@ class InfluenceSpent(WarEvent): amount: int, context_type: str, context_id: str, + tie_id: str, objective_id: str | None = None, + sector_id: str | None = None, ): - super().__init__(participant_id, context_type, context_id) + super().__init__(participant_id, context_type, context_id, tie_id) self.amount = amount self.objective_id = objective_id + self.sector_id = sector_id def toDict(self) -> Dict[str, Any]: d = super().toDict() @@ -168,6 +181,7 @@ class InfluenceSpent(WarEvent): { "amount": self.amount, "objective_id": self.objective_id, + "sector_id": self.sector_id or None, } ) return d @@ -179,6 +193,8 @@ class InfluenceSpent(WarEvent): int(data["amount"]), data["context_type"], data["context_id"], + data["tie_id"], JsonHelper.none_if_empty(data["objective_id"]), + JsonHelper.none_if_empty(data["sector_id"]), ) return cls._base_fromDict(ev, data) diff --git a/test_data/example.json b/test_data/example.json index d40dfa2..b902826 100644 --- a/test_data/example.json +++ b/test_data/example.json @@ -338,7 +338,12 @@ "participant_id": null, "context_type": "battle", "context_id": "79accf7c-2d93-4ac3-b747-e7092bfe3feb", + "participants": [ + "602e2eaf-297e-490b-b0e9-efec818e466a", + "1f6b4e7c-b1e4-4a2e-9aea-7bb75b20b4de" + ], "timestamp": "2026-02-26T16:11:44.346337", + "tie_id": null, "score_value": null, "objective_id": null, "sector_id": null