From db78c6dacc98a475c1a330041a6f9e9306c674c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20R=C3=A9aux?= Date: Wed, 18 Mar 2026 09:26:43 +0100 Subject: [PATCH] fix mismatch part_id in choice event + fix revert events on sector/participant removal --- src/warchron/model/campaign.py | 6 ++ src/warchron/model/model.py | 1 + src/warchron/model/pairing.py | 12 ++-- src/warchron/model/round.py | 38 +++++++--- src/warchron/model/war.py | 126 +++++++++++++++++++++++++-------- test_data/example.json | 15 ++-- 6 files changed, 145 insertions(+), 53 deletions(-) diff --git a/src/warchron/model/campaign.py b/src/warchron/model/campaign.py index 4efc374..15fd545 100644 --- a/src/warchron/model/campaign.py +++ b/src/warchron/model/campaign.py @@ -23,6 +23,12 @@ class Campaign: self.is_over = False self._war: War | None = None # private link + @property + def war(self) -> "War": + if self._war is None: + raise RuntimeError("Campaign is not linked to a War") + return self._war + def set_id(self, new_id: str) -> None: self.id = new_id diff --git a/src/warchron/model/model.py b/src/warchron/model/model.py index ca6dc23..312431a 100644 --- a/src/warchron/model/model.py +++ b/src/warchron/model/model.py @@ -49,6 +49,7 @@ class Model: self.players[player.id] = player for w in data.get("wars", []): war = War.fromDict(w) + war.relink() self.wars[war.id] = war except json.JSONDecodeError: raise RuntimeError("Data file is corrupted") diff --git a/src/warchron/model/pairing.py b/src/warchron/model/pairing.py index 5936e36..24e77f9 100644 --- a/src/warchron/model/pairing.py +++ b/src/warchron/model/pairing.py @@ -186,12 +186,13 @@ class Pairing: campaign = war.get_campaign_by_round(round.id) if campaign is None: raise DomainError("Campaign not found for round {round.id}") + war_participants = [ + campaign.campaign_to_war_part_id(pid) for pid in participants + ] context = TieContext( context_type=ContextType.CHOICE, context_id=round.id, - participants=[ - campaign.campaign_to_war_part_id(pid) for pid in participants - ], + participants=war_participants, score_value=score_value, score_kind=ScoreKind.VP, sector_id=sector_id, @@ -212,6 +213,7 @@ class Pairing: score_kind=context.score_kind, sector_id=context.sector_id, ) + # natural or unbreakable draw if not TieResolver.can_tie_be_resolved( war, context, current_context.participants ): @@ -220,7 +222,7 @@ class Pairing: None, context.context_type, context.context_id, - participants, + participants=context.participants, tie_id=tie_id, score_value=score_value, sector_id=sector_id, @@ -254,7 +256,6 @@ class Pairing: 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 @@ -266,6 +267,7 @@ class Pairing: round: Round, remaining: List[str], ) -> None: + # TODO avoid rematch for pid in list(remaining): available = round.get_battles_with_places() if not available: diff --git a/src/warchron/model/round.py b/src/warchron/model/round.py index f7d8039..05b7935 100644 --- a/src/warchron/model/round.py +++ b/src/warchron/model/round.py @@ -4,6 +4,7 @@ from typing import Any, Dict, List, TYPE_CHECKING if TYPE_CHECKING: from warchron.model.campaign import Campaign + from warchron.model.war import War from warchron.model.exception import ForbiddenOperation from warchron.model.choice import Choice from warchron.model.battle import Battle @@ -17,6 +18,16 @@ class Round: self.is_over: bool = False self._campaign: Campaign | None = None # private link + @property + def campaign(self) -> "Campaign": + if self._campaign is None: + raise RuntimeError("Round is not linked to a Campaign") + return self._campaign + + @property + def war(self) -> "War": + return self.campaign.war + def set_id(self, new_id: str) -> None: self.id = new_id @@ -91,7 +102,6 @@ class Round: 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 @@ -102,18 +112,19 @@ class Round: 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) + self.war.revert_choice_ties(self.id, sector_id=sector_id) def remove_choice(self, participant_id: str) -> None: + if participant_id not in self.choices: + return 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] - ) + self.war.revert_choice_ties( + self.id, + participants=[self.campaign.campaign_to_war_part_id(participant_id)], + ) del self.choices[participant_id] # Battle methods @@ -187,10 +198,16 @@ class Round: 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) + self.war.revert_battle_ties( + self.id, + participants=[ + self.campaign.campaign_to_war_part_id(participant_id) + ], + ) def remove_battle(self, sector_id: str) -> None: + if sector_id not in self.battles: + return if self.is_over: # TODO catch me if you can raise ForbiddenOperation("Can't remove battle in a closed round.") @@ -198,6 +215,5 @@ class Round: 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) + self.war.revert_battle_ties(self.id, sector_id=sector_id) del self.battles[sector_id] diff --git a/src/warchron/model/war.py b/src/warchron/model/war.py index b7bc964..d7c0f28 100644 --- a/src/warchron/model/war.py +++ b/src/warchron/model/war.py @@ -161,6 +161,12 @@ class War: war.set_state(data.get("is_over", False)) return war + def relink(self) -> None: + for campaign in self.campaigns: + campaign._war = self + for rnd in campaign.rounds: + rnd._campaign = campaign + # Objective methods def add_objective(self, name: str, description: str | None) -> Objective: @@ -552,12 +558,60 @@ class War: ) return gained - spent - def get_events_by_ties_session(self, tie_id: str) -> List[WarEvent]: + def get_events_by_tie_id(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: + def remove_events_by_tie_id(self, tie_id: str) -> None: self.events = [ev for ev in self.events if ev.tie_id != tie_id] + def find_ties( + self, + *, + context_type: str, + context_id: str, + sector_id: str | None = None, + participants: List[str] | None = None, + ) -> Set[str]: + ties = set() + for ev in self.events: + if not isinstance(ev, TieResolved): + continue + if ev.context_type != context_type: + continue + if ev.context_id != context_id: + continue + if sector_id and ev.sector_id != sector_id: + continue + if participants and not any(p in ev.participants for p in participants): + continue + if ev.tie_id: + ties.add(ev.tie_id) + return ties + + def get_draws( + self, + *, + context_type: str, + context_id: str, + sector_id: str | None = None, + participants: List[str] | None = None, + ) -> List[WarEvent]: + draws: List[WarEvent] = list() + for ev in self.events: + if not isinstance(ev, TieResolved): + continue + if ev.context_type != context_type: + continue + if ev.context_id != context_id: + continue + if sector_id and ev.sector_id != sector_id: + continue + if participants and not any(p in ev.participants for p in participants): + continue + if ev.tie_id is None: + draws.append(ev) + return draws + def revert_choice_ties( self, round_id: str, @@ -565,33 +619,45 @@ class War: 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 + removed_ties = self.find_ties( + context_type=ContextType.CHOICE, + context_id=round_id, + sector_id=sector_id, + participants=participants, + ) + draws = self.get_draws( + context_type=ContextType.CHOICE, + context_id=round_id, + sector_id=sector_id, + participants=participants, + ) + self.events = [ + ev 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] + if ev.tie_id not in removed_ties and ev not in draws + ] - def revert_tie(self, tie_id: str) -> None: - self.events = [ev for ev in self.events if ev.tie_id != tie_id] + def revert_battle_ties( + self, + round_id: str, + *, + sector_id: str | None = None, + participants: List[str] | None = None, + ) -> None: + removed_ties = self.find_ties( + context_type=ContextType.BATTLE, + context_id=round_id, + sector_id=sector_id, + participants=participants, + ) + draws = self.get_draws( + context_type=ContextType.BATTLE, + context_id=round_id, + sector_id=sector_id, + participants=participants, + ) + self.events = [ + ev + for ev in self.events + if ev.tie_id not in removed_ties and ev not in draws + ] diff --git a/test_data/example.json b/test_data/example.json index b902826..7b8484a 100644 --- a/test_data/example.json +++ b/test_data/example.json @@ -325,25 +325,26 @@ "events": [ { "type": "InfluenceGained", - "id": "8f20b587-0ff3-42c9-9965-88112e2a4e74", + "id": "096802e2-5833-4c6d-941d-c91d98957fc1", "participant_id": "accc25f2-43a0-4d41-804f-3c8aec853c97", "context_type": "battle", "context_id": "4548997e-50e5-493a-b751-483f5bcccc00", - "timestamp": "2026-02-26T16:10:54.125407", + "timestamp": "2026-03-18T09:25:03.746744", + "tie_id": null, "amount": 1 }, { "type": "TieResolved", - "id": "c83a9d4e-4620-47de-93c8-d29d6e119c59", + "id": "ec6b8932-b24f-4042-a8f4-566a42d0857d", "participant_id": null, "context_type": "battle", "context_id": "79accf7c-2d93-4ac3-b747-e7092bfe3feb", + "timestamp": "2026-03-18T09:25:08.578539", + "tie_id": "07aa41e5-1696-4b9f-931b-2540e213c7ef", "participants": [ - "602e2eaf-297e-490b-b0e9-efec818e466a", - "1f6b4e7c-b1e4-4a2e-9aea-7bb75b20b4de" + "accc25f2-43a0-4d41-804f-3c8aec853c97", + "179bdab6-8630-4a92-8fb6-d2637562c66c" ], - "timestamp": "2026-02-26T16:11:44.346337", - "tie_id": null, "score_value": null, "objective_id": null, "sector_id": null