fix choice tiebreak loop and cancel tiebreak lost tokens

This commit is contained in:
Maxime Réaux 2026-03-17 11:16:47 +01:00
parent a3b9f5a943
commit 42ad708e77
13 changed files with 333 additions and 129 deletions

View file

@ -167,7 +167,7 @@ class CampaignController:
def resolve_ties( def resolve_ties(
self, war: War, contexts: List[TieContext] 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 = {} bids_map = {}
for ctx in contexts: for ctx in contexts:
active = TieResolver.get_active_participants(war, ctx, ctx.participants) active = TieResolver.get_active_participants(war, ctx, ctx.participants)
@ -180,7 +180,7 @@ class CampaignController:
parent=self.app.view, parent=self.app.view,
players=players, players=players,
counters=counters, counters=counters,
context_type=ContextType.CAMPAIGN, context_type=ctx.context_type,
context_id=ctx.context_id, context_id=ctx.context_id,
context_name=None, context_name=None,
) )
@ -192,7 +192,7 @@ class CampaignController:
counters=counters, counters=counters,
context_type=ctx.context_type, context_type=ctx.context_type,
context_id=ctx.context_id, context_id=ctx.context_id,
context_name=objective.name, context_name=f"Objective tie: {objective.name}",
) )
if not dialog.exec(): if not dialog.exec():
TieResolver.cancel_tie_break(war, ctx) TieResolver.cancel_tie_break(war, ctx)

View file

@ -1,4 +1,5 @@
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from uuid import uuid4
if TYPE_CHECKING: if TYPE_CHECKING:
from warchron.controller.app_controller import AppController from warchron.controller.app_controller import AppController
@ -25,8 +26,9 @@ class RoundClosureWorkflow(ClosureWorkflow):
bids_map = self.app.rounds.resolve_ties(war, ties) bids_map = self.app.rounds.resolve_ties(war, ties)
for tie in ties: for tie in ties:
bids = bids_map[tie.key()] bids = bids_map[tie.key()]
TieResolver.apply_bids(war, tie, bids) tie_id = TieResolver.find_active_tie_id(war, tie) or str(uuid4())
TieResolver.resolve_tie_state(war, tie, bids) 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) ties = TieResolver.find_battle_ties(war, round.id)
for battle in round.battles.values(): for battle in round.battles.values():
ClosureService.apply_battle_outcomes(war, campaign, battle) ClosureService.apply_battle_outcomes(war, campaign, battle)
@ -42,8 +44,9 @@ class CampaignClosureWorkflow(ClosureWorkflow):
bids_map = self.app.campaigns.resolve_ties(war, ties) bids_map = self.app.campaigns.resolve_ties(war, ties)
for tie in ties: for tie in ties:
bids = bids_map[tie.key()] bids = bids_map[tie.key()]
TieResolver.apply_bids(war, tie, bids) tie_id = TieResolver.find_active_tie_id(war, tie) or str(uuid4())
TieResolver.resolve_tie_state(war, tie, bids) 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) ties = TieResolver.find_campaign_ties(war, campaign.id)
for obj in war.get_objectives_used_as_maj_or_min(): for obj in war.get_objectives_used_as_maj_or_min():
objective_id = obj.id objective_id = obj.id
@ -56,8 +59,9 @@ class CampaignClosureWorkflow(ClosureWorkflow):
bids_map = self.app.campaigns.resolve_ties(war, ties) bids_map = self.app.campaigns.resolve_ties(war, ties)
for tie in ties: for tie in ties:
bids = bids_map[tie.key()] bids = bids_map[tie.key()]
TieResolver.apply_bids(war, tie, bids) tie_id = TieResolver.find_active_tie_id(war, tie) or str(uuid4())
TieResolver.resolve_tie_state(war, tie, bids) TieResolver.apply_bids(war, tie, tie_id, bids)
TieResolver.resolve_tie_state(war, tie, tie_id, bids)
ties = TieResolver.find_campaign_objective_ties( ties = TieResolver.find_campaign_objective_ties(
war, war,
campaign.id, campaign.id,
@ -75,8 +79,9 @@ class WarClosureWorkflow(ClosureWorkflow):
bids_map = self.app.wars.resolve_ties(war, ties) bids_map = self.app.wars.resolve_ties(war, ties)
for tie in ties: for tie in ties:
bids = bids_map[tie.key()] bids = bids_map[tie.key()]
TieResolver.apply_bids(war, tie, bids) tie_id = TieResolver.find_active_tie_id(war, tie) or str(uuid4())
TieResolver.resolve_tie_state(war, tie, bids) TieResolver.apply_bids(war, tie, tie_id, bids)
TieResolver.resolve_tie_state(war, tie, tie_id, bids)
ties = TieResolver.find_war_ties(war) ties = TieResolver.find_war_ties(war)
for obj in war.get_objectives_used_as_maj_or_min(): for obj in war.get_objectives_used_as_maj_or_min():
objective_id = obj.id objective_id = obj.id
@ -88,8 +93,9 @@ class WarClosureWorkflow(ClosureWorkflow):
bids_map = self.app.wars.resolve_ties(war, ties) bids_map = self.app.wars.resolve_ties(war, ties)
for tie in ties: for tie in ties:
bids = bids_map[tie.key()] bids = bids_map[tie.key()]
TieResolver.apply_bids(war, tie, bids) tie_id = TieResolver.find_active_tie_id(war, tie) or str(uuid4())
TieResolver.resolve_tie_state(war, tie, bids) TieResolver.apply_bids(war, tie, tie_id, bids)
TieResolver.resolve_tie_state(war, tie, tie_id, bids)
ties = TieResolver.find_war_objective_ties( ties = TieResolver.find_war_objective_ties(
war, war,
objective_id, objective_id,
@ -103,7 +109,7 @@ class RoundPairingWorkflow:
self.app = controller self.app = controller
def start(self, war: War, round: Round) -> None: def start(self, war: War, round: Round) -> None:
Pairing.check_round_pairable(round) Pairing.check_round_pairable(war, round)
Pairing.assign_battles_to_participants( Pairing.assign_battles_to_participants(
war, war,
round, round,

View file

@ -72,6 +72,7 @@ class RoundController:
comment=choice.comment, comment=choice.comment,
) )
) )
# TODO display allocated sectors and used token
self.app.view.display_round_choices(choices_for_display) self.app.view.display_round_choices(choices_for_display)
battles_for_display: List[BattleDTO] = [] battles_for_display: List[BattleDTO] = []
for sect in sectors: for sect in sectors:
@ -89,6 +90,7 @@ class RoundController:
player_1_name = self.app.model.get_participant_name( player_1_name = self.app.model.get_participant_name(
camp_part.war_participant_id camp_part.war_participant_id
) )
p1_id = battle.player_1_id
else: else:
player_1_name = "" player_1_name = ""
if battle.player_2_id: if battle.player_2_id:
@ -96,6 +98,7 @@ class RoundController:
player_2_name = self.app.model.get_participant_name( player_2_name = self.app.model.get_participant_name(
camp_part.war_participant_id camp_part.war_participant_id
) )
p2_id = battle.player_2_id
else: else:
player_2_name = "" player_2_name = ""
if battle.winner_id: if battle.winner_id:
@ -112,7 +115,11 @@ class RoundController:
if battle.is_draw(): if battle.is_draw():
p1_icon = Icons.get(IconName.DRAW) p1_icon = Icons.get(IconName.DRAW)
p2_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): if TieResolver.was_tie_broken_by_tokens(war, context):
effective_winner = ResultChecker.get_effective_winner_id( effective_winner = ResultChecker.get_effective_winner_id(
war, ContextType.BATTLE, battle.sector_id, None war, ContextType.BATTLE, battle.sector_id, None
@ -197,7 +204,7 @@ class RoundController:
str(e), str(e),
) )
for bat in rnd.battles.values(): for bat in rnd.battles.values():
bat.cleanup_battle_players() bat.clear_battle_players()
return return
except DomainError as e: except DomainError as e:
QMessageBox.warning( QMessageBox.warning(
@ -225,7 +232,7 @@ class RoundController:
def resolve_ties( def resolve_ties(
self, war: War, contexts: List[TieContext] 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 = {} bids_map = {}
for ctx in contexts: for ctx in contexts:
players = [ players = [
@ -236,11 +243,12 @@ class RoundController:
for pid in ctx.participants for pid in ctx.participants
] ]
counters = [war.get_influence_tokens(pid) 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( dialog = TieDialog(
parent=self.app.view, parent=self.app.view,
players=players, players=players,
counters=counters, counters=counters,
context_type=ContextType.BATTLE, context_type=ctx.context_type,
context_id=ctx.context_id, context_id=ctx.context_id,
) )
if not dialog.exec(): if not dialog.exec():

View file

@ -58,7 +58,7 @@ class WarController:
] ]
scores = ScoreService.compute_scores(war, ContextType.WAR, war.id) scores = ScoreService.compute_scores(war, ContextType.WAR, war.id)
rows: List[WarParticipantScoreDTO] = [] rows: List[WarParticipantScoreDTO] = []
vp_icon_map: dict[str, QIcon] = {} vp_icon_map: Dict[str, QIcon] = {}
objective_icon_maps: Dict[str, Dict[str, QIcon]] = {} objective_icon_maps: Dict[str, Dict[str, QIcon]] = {}
if war.is_over: if war.is_over:
vp_icon_map = RankingIcon.compute_icons( vp_icon_map = RankingIcon.compute_icons(
@ -157,7 +157,7 @@ class WarController:
def resolve_ties( def resolve_ties(
self, war: War, contexts: List[TieContext] 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 = {} bids_map = {}
for ctx in contexts: for ctx in contexts:
active = TieResolver.get_active_participants( active = TieResolver.get_active_participants(
@ -174,7 +174,7 @@ class WarController:
parent=self.app.view, parent=self.app.view,
players=players, players=players,
counters=counters, counters=counters,
context_type=ContextType.WAR, context_type=ctx.context_type,
context_id=ctx.context_id, context_id=ctx.context_id,
context_name=None, context_name=None,
) )

View file

@ -57,7 +57,7 @@ class Battle:
return False return False
def get_available_places(self) -> List[str]: def get_available_places(self) -> List[str]:
places: list[str] = [] places: List[str] = []
if self.player_1_id is None: if self.player_1_id is None:
places.append("player_1") places.append("player_1")
if self.player_2_id is None: if self.player_2_id is None:
@ -73,7 +73,7 @@ class Battle:
return return
raise DomainError("Battle has no available places") 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_1_id = None
self.player_2_id = None self.player_2_id = None

View file

@ -1,7 +1,9 @@
from __future__ import annotations from __future__ import annotations
from uuid import uuid4 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.exception import ForbiddenOperation, RequiresConfirmation
from warchron.model.campaign_participant import CampaignParticipant from warchron.model.campaign_participant import CampaignParticipant
from warchron.model.sector import Sector from warchron.model.sector import Sector
@ -19,6 +21,7 @@ class Campaign:
self.sectors: Dict[str, Sector] = {} self.sectors: Dict[str, Sector] = {}
self.rounds: List[Round] = [] self.rounds: List[Round] = []
self.is_over = False self.is_over = False
self._war: War | None = None # private link
def set_id(self, new_id: str) -> None: def set_id(self, new_id: str) -> None:
self.id = new_id self.id = new_id
@ -60,8 +63,8 @@ class Campaign:
# Campaign participant methods # Campaign participant methods
def get_all_campaign_participants_ids(self) -> set[str]: def get_all_campaign_participants_ids(self) -> List[str]:
return set(self.participants.keys()) return list(self.participants.keys())
def has_participant(self, participant_id: str) -> bool: def has_participant(self, participant_id: str) -> bool:
return participant_id in self.participants return participant_id in self.participants
@ -351,6 +354,7 @@ class Campaign:
if self.is_over: if self.is_over:
raise ForbiddenOperation("Can't add round in a closed campaign.") raise ForbiddenOperation("Can't add round in a closed campaign.")
round = Round() round = Round()
round._campaign = self
self.rounds.append(round) self.rounds.append(round)
return round return round

View file

@ -360,8 +360,8 @@ class Model:
) )
def remove_sector(self, sector_id: str) -> None: def remove_sector(self, sector_id: str) -> None:
camp = self.get_campaign_by_sector(sector_id) war = self.get_war_by_sector(sector_id)
camp.remove_sector(sector_id) war.remove_sector(sector_id)
# Campaign participant methods # Campaign participant methods

View file

@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Dict, List, Callable, Tuple from typing import Dict, List, Callable, Tuple
from uuid import uuid4
import random import random
from warchron.constants import ContextType, ScoreKind from warchron.constants import ContextType, ScoreKind
@ -18,7 +20,7 @@ from warchron.model.score_service import ParticipantScore
ResolveTiesCallback = Callable[ ResolveTiesCallback = Callable[
["War", List["TieContext"]], ["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 @staticmethod
def check_round_pairable( def check_round_pairable(
war: War,
round: Round, round: Round,
) -> None: ) -> None:
if round.is_over: if round.is_over:
@ -48,8 +51,9 @@ class Pairing:
def cleanup() -> None: def cleanup() -> None:
for bat in round.battles.values(): for bat in round.battles.values():
bat.cleanup_battle_players() bat.clear_battle_players()
# FIXME cancel TieResolved + TokenSpent bat.set_winner(None)
war.revert_choice_ties(round.id)
if any( if any(
bat.player_1_id is not None or bat.player_2_id is not None bat.player_1_id is not None or bat.player_2_id is not None
@ -58,6 +62,7 @@ class Pairing:
raise RequiresConfirmation( raise RequiresConfirmation(
"Battle(s) already have player(s) assigned for this round.\n" "Battle(s) already have player(s) assigned for this round.\n"
"Battle players will be cleared.\n" "Battle players will be cleared.\n"
"Choice tokens and tie-breaks will be deleted.\n"
"Do you want to continue?", "Do you want to continue?",
action=cleanup, action=cleanup,
) )
@ -90,20 +95,20 @@ class Pairing:
campaign.war_to_campaign_part_id(pid) for pid in group campaign.war_to_campaign_part_id(pid) for pid in group
] ]
Pairing._run_phase( Pairing._run_phase(
war, war=war,
round, round=round,
remaining, remaining=remaining,
sector_to_battle, sector_to_battle=sector_to_battle,
resolve_ties_callback, resolve_ties_callback=resolve_ties_callback,
use_priority=True, use_priority=True,
score_value=score_value, score_value=score_value,
) )
Pairing._run_phase( Pairing._run_phase(
war, war=war,
round, round=round,
remaining, remaining=remaining,
sector_to_battle, sector_to_battle=sector_to_battle,
resolve_ties_callback, resolve_ties_callback=resolve_ties_callback,
use_priority=False, use_priority=False,
score_value=score_value, score_value=score_value,
) )
@ -133,13 +138,13 @@ class Pairing:
if places <= 0: if places <= 0:
continue continue
winners = Pairing._resolve_sector_allocation( winners = Pairing._resolve_sector_allocation(
war, war=war,
round, round=round,
sector_id, sector_id=sector_id,
participants, participants=participants,
places, places=places,
resolve_ties_callback, resolve_ties_callback=resolve_ties_callback,
score_value, score_value=score_value,
) )
for pid in winners: for pid in winners:
battle.assign_participant(pid) battle.assign_participant(pid)
@ -187,26 +192,59 @@ class Pairing:
participants=[ participants=[
campaign.campaign_to_war_part_id(pid) for pid in participants campaign.campaign_to_war_part_id(pid) for pid in participants
], ],
score_value=score_value,
score_kind=ScoreKind.VP, score_kind=ScoreKind.VP,
sector_id=sector_id, sector_id=sector_id,
) )
# ---- resolve tie loop ---- # ---- resolve tie loop ----
tie_id = TieResolver.find_active_tie_id(war, context) or str(uuid4())
while not TieResolver.is_tie_resolved(war, context): 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( war.events.append(
TieResolved( TieResolved(
None, None,
context.context_type, context.context_type,
context.context_id, context.context_id,
participants,
tie_id=tie_id,
score_value=score_value, score_value=score_value,
sector_id=sector_id, sector_id=sector_id,
) )
) )
break break
bids_map = resolve_ties_callback(war, [context]) bids_map = resolve_ties_callback(war, [current_context])
bids = bids_map[context.key()] bids = bids_map[current_context.key()]
TieResolver.apply_bids(war, context, bids) # confirmed draw if current bids are 0
TieResolver.resolve_tie_state(war, context, bids) 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( ranked_groups = TieResolver.rank_by_tokens(
war, war,
context, context,
@ -215,6 +253,8 @@ class Pairing:
ordered: List[str] = [] ordered: List[str] = []
for group in ranked_groups: for group in ranked_groups:
shuffled_group = list(group) shuffled_group = list(group)
# TODO improve tie break with history parsing
# TODO avoid rematch
random.shuffle(shuffled_group) random.shuffle(shuffled_group)
ordered.extend( ordered.extend(
campaign.war_to_campaign_part_id(pid) for pid in shuffled_group campaign.war_to_campaign_part_id(pid) for pid in shuffled_group

View file

@ -1,7 +1,9 @@
from __future__ import annotations from __future__ import annotations
from uuid import uuid4 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.exception import ForbiddenOperation
from warchron.model.choice import Choice from warchron.model.choice import Choice
from warchron.model.battle import Battle from warchron.model.battle import Battle
@ -13,6 +15,7 @@ class Round:
self.choices: Dict[str, Choice] = {} self.choices: Dict[str, Choice] = {}
self.battles: Dict[str, Battle] = {} self.battles: Dict[str, Battle] = {}
self.is_over: bool = False self.is_over: bool = False
self._campaign: Campaign | None = None # private link
def set_id(self, new_id: str) -> None: def set_id(self, new_id: str) -> None:
self.id = new_id self.id = new_id
@ -60,6 +63,7 @@ class Round:
def create_choice(self, participant_id: str) -> Choice: def create_choice(self, participant_id: str) -> Choice:
if self.is_over: if self.is_over:
# TODO catch me if you can
raise ForbiddenOperation("Can't create choice in a closed round.") raise ForbiddenOperation("Can't create choice in a closed round.")
if participant_id not in self.choices: if participant_id not in self.choices:
choice = Choice( choice = Choice(
@ -78,23 +82,38 @@ class Round:
comment: str | None, comment: str | None,
) -> None: ) -> None:
if self.is_over: if self.is_over:
# TODO catch me if you can
raise ForbiddenOperation("Can't update choice in a closed round.") raise ForbiddenOperation("Can't update choice in a closed round.")
# TODO prevent if battles already assigned
choice = self.get_choice(participant_id) choice = self.get_choice(participant_id)
if choice: if choice:
choice.set_priority(priority_sector_id) choice.set_priority(priority_sector_id)
choice.set_secondary(secondary_sector_id) choice.set_secondary(secondary_sector_id)
choice.set_comment(comment) choice.set_comment(comment)
# FIXME remove corresponding InfluenceSpent and TieResolved
def clear_sector_references(self, sector_id: str) -> None: def clear_sector_references(self, sector_id: str) -> None:
for choice in self.choices.values(): for choice in self.choices.values():
trigger_revert_ties = False
if choice.priority_sector_id == sector_id: if choice.priority_sector_id == sector_id:
choice.priority_sector_id = None choice.priority_sector_id = None
trigger_revert_ties = True
if choice.secondary_sector_id == sector_id: if choice.secondary_sector_id == sector_id:
choice.secondary_sector_id = None 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: def remove_choice(self, participant_id: str) -> None:
if self.is_over: if self.is_over:
# TODO catch me if you can (inner)
raise ForbiddenOperation("Can't remove choice in a closed round.") 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] del self.choices[participant_id]
# Battle methods # Battle methods
@ -126,6 +145,7 @@ class Round:
def create_battle(self, sector_id: str) -> Battle: def create_battle(self, sector_id: str) -> Battle:
if self.is_over: if self.is_over:
# TODO catch me if you can
raise ForbiddenOperation("Can't create battle in a closed round.") raise ForbiddenOperation("Can't create battle in a closed round.")
if sector_id not in self.battles: if sector_id not in self.battles:
battle = Battle(sector_id=sector_id, player_1_id=None, player_2_id=None) battle = Battle(sector_id=sector_id, player_1_id=None, player_2_id=None)
@ -143,8 +163,10 @@ class Round:
comment: str | None, comment: str | None,
) -> None: ) -> None:
if self.is_over: if self.is_over:
# TODO catch me if you can
raise ForbiddenOperation("Can't update battle in a closed round.") raise ForbiddenOperation("Can't update battle in a closed round.")
bat = self.get_battle(sector_id) bat = self.get_battle(sector_id)
# TODO require confirmation if there was choice tie to clear it
if bat: if bat:
bat.set_player_1(player_1_id) bat.set_player_1(player_1_id)
bat.set_player_2(player_2_id) bat.set_player_2(player_2_id)
@ -155,17 +177,27 @@ class Round:
def clear_participant_references(self, participant_id: str) -> None: def clear_participant_references(self, participant_id: str) -> None:
for battle in self.battles.values(): for battle in self.battles.values():
trigger_revert_ties = False
if battle.player_1_id == participant_id: if battle.player_1_id == participant_id:
battle.player_1_id = None battle.player_1_id = None
trigger_revert_ties = True
if battle.player_2_id == participant_id: if battle.player_2_id == participant_id:
battle.player_2_id = None battle.player_2_id = None
trigger_revert_ties = True
if battle.winner_id == participant_id: if battle.winner_id == participant_id:
battle.winner_id = None 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: def remove_battle(self, sector_id: str) -> None:
if self.is_over: if self.is_over:
# TODO catch me if you can
raise ForbiddenOperation("Can't remove battle in a closed round.") raise ForbiddenOperation("Can't remove battle in a closed round.")
bat = self.battles[sector_id] bat = self.battles[sector_id]
if bat and bat.is_finished(): if bat and bat.is_finished():
# TODO catch me if you can
raise ForbiddenOperation("Can't remove finished battle.") 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] del self.battles[sector_id]

View file

@ -1,5 +1,5 @@
from typing import List, Dict, DefaultDict from typing import List, Dict, DefaultDict, Tuple
from dataclasses import dataclass, field from dataclasses import dataclass
from collections import defaultdict from collections import defaultdict
from warchron.constants import ContextType, ScoreKind from warchron.constants import ContextType, ScoreKind
@ -13,41 +13,70 @@ from warchron.model.score_service import ScoreService, ParticipantScore
class TieContext: class TieContext:
context_type: ContextType context_type: ContextType
context_id: str context_id: str
participants: List[str] = field(default_factory=list) # war_participant_ids participants: List[str]
score_value: int | None = None score_value: int | None = None
score_kind: ScoreKind | None = None score_kind: ScoreKind | None = None
objective_id: str | None = None objective_id: str | None = None
sector_id: str | None = None sector_id: str | None = None
def key(self) -> tuple[str, str, int | None]: def key(self) -> Tuple[str, str, int | None, str | None, str | None]:
return (self.context_type, self.context_id, self.score_value) return (
self.context_type,
self.context_id,
self.score_value,
self.objective_id,
self.sector_id,
)
class TieResolver: 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 @staticmethod
def find_battle_ties(war: War, round_id: str) -> List[TieContext]: def find_battle_ties(war: War, round_id: str) -> List[TieContext]:
round = war.get_round(round_id) round = war.get_round(round_id)
campaign = war.get_campaign_by_round(round_id) campaign = war.get_campaign_by_round(round_id)
ties = [] ties = []
for battle in round.battles.values(): for battle in round.battles.values():
if not battle.is_draw():
continue
context: TieContext = TieContext(
ContextType.BATTLE,
battle.sector_id,
)
if TieResolver.is_tie_resolved(war, context):
continue
if campaign is None: if campaign is None:
raise DomainError("No campaign for this battle tie") raise DomainError("No campaign for this battle tie")
if battle.player_1_id is None or battle.player_2_id is None: if battle.player_1_id is None or battle.player_2_id is None:
raise DomainError("Missing player(s) in this battle context.") raise DomainError("Missing player(s) in this battle context.")
p1_id = campaign.campaign_to_war_part_id(battle.player_1_id) p1_id = campaign.campaign_to_war_part_id(battle.player_1_id)
p2_id = campaign.campaign_to_war_part_id(battle.player_2_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]): if not TieResolver.can_tie_be_resolved(war, context, [p1_id, p2_id]):
war.events.append( 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 continue
ties.append( ties.append(
@ -80,13 +109,16 @@ class TieResolver:
) )
if TieResolver.is_tie_resolved(war, context): if TieResolver.is_tie_resolved(war, context):
continue continue
tie_id = TieResolver.find_active_tie_id(war, context)
if not TieResolver.can_tie_be_resolved(war, context, participants): if not TieResolver.can_tie_be_resolved(war, context, participants):
war.events.append( war.events.append(
TieResolved( TieResolved(
None, participant_id=None,
ContextType.CAMPAIGN, context_type=ContextType.CAMPAIGN,
campaign_id, context_id=campaign_id,
score_value, participants=participants,
tie_id=tie_id,
score_value=score_value,
) )
) )
continue continue
@ -103,9 +135,7 @@ class TieResolver:
@staticmethod @staticmethod
def find_campaign_objective_ties( def find_campaign_objective_ties(
war: War, war: War, campaign_id: str, objective_id: str
campaign_id: str,
objective_id: str,
) -> List[TieContext]: ) -> List[TieContext]:
scores = ScoreService.compute_scores( scores = ScoreService.compute_scores(
war, war,
@ -131,6 +161,7 @@ class TieResolver:
) )
if TieResolver.is_tie_resolved(war, context): if TieResolver.is_tie_resolved(war, context):
continue continue
tie_id = TieResolver.find_active_tie_id(war, context)
if not TieResolver.can_tie_be_resolved( if not TieResolver.can_tie_be_resolved(
war, war,
context, context,
@ -138,7 +169,13 @@ class TieResolver:
): ):
war.events.append( war.events.append(
TieResolved( 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 continue
@ -181,9 +218,17 @@ class TieResolver:
) )
if TieResolver.is_tie_resolved(war, context): if TieResolver.is_tie_resolved(war, context):
continue continue
tie_id = TieResolver.find_active_tie_id(war, context)
if not TieResolver.can_tie_be_resolved(war, context, group): if not TieResolver.can_tie_be_resolved(war, context, group):
war.events.append( 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 continue
ties.append( ties.append(
@ -198,10 +243,7 @@ class TieResolver:
return ties return ties
@staticmethod @staticmethod
def find_war_objective_ties( def find_war_objective_ties(war: War, objective_id: str) -> List[TieContext]:
war: War,
objective_id: str,
) -> List[TieContext]:
from warchron.model.result_checker import ResultChecker from warchron.model.result_checker import ResultChecker
scores = ScoreService.compute_scores( scores = ScoreService.compute_scores(
@ -235,13 +277,22 @@ class TieResolver:
context, context,
): ):
continue continue
tie_id = TieResolver.find_active_tie_id(war, context)
if not TieResolver.can_tie_be_resolved( if not TieResolver.can_tie_be_resolved(
war, war,
context, context,
group, group,
): ):
war.events.append( 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 continue
ties.append( ties.append(
@ -260,6 +311,7 @@ class TieResolver:
def apply_bids( def apply_bids(
war: War, war: War,
context: TieContext, context: TieContext,
tie_id: str,
bids: Dict[str, bool], # war_participant_id -> spend? bids: Dict[str, bool], # war_participant_id -> spend?
) -> None: ) -> None:
for war_part_id, spend in bids.items(): for war_part_id, spend in bids.items():
@ -273,6 +325,7 @@ class TieResolver:
amount=1, amount=1,
context_type=context.context_type, context_type=context.context_type,
context_id=context.context_id, context_id=context.context_id,
tie_id=tie_id,
objective_id=context.objective_id, objective_id=context.objective_id,
) )
) )
@ -287,12 +340,7 @@ class TieResolver:
for ev in war.events for ev in war.events
if not ( if not (
( (
isinstance(ev, InfluenceSpent) (isinstance(ev, InfluenceSpent) or isinstance(ev, TieResolved))
and ev.context_type == context.context_type
and ev.context_id == context.context_id
)
or (
isinstance(ev, TieResolved)
and ev.context_type == context.context_type and ev.context_type == context.context_type
and ev.context_id == context.context_id and ev.context_id == context.context_id
) )
@ -356,25 +404,9 @@ class TieResolver:
def resolve_tie_state( def resolve_tie_state(
war: War, war: War,
context: TieContext, context: TieContext,
bids: dict[str, bool] | None = None, tie_id: str,
bids: Dict[str, bool] | None = 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 # confirmed draw if current bids are 0
if bids is not None and not any(bids.values()): if bids is not None and not any(bids.values()):
war.events.append( war.events.append(
@ -382,12 +414,13 @@ class TieResolver:
None, None,
context.context_type, context.context_type,
context.context_id, context.context_id,
participants=context.participants,
tie_id=tie_id,
score_value=context.score_value, score_value=context.score_value,
objective_id=context.objective_id, objective_id=context.objective_id,
) )
) )
return return
# else rank_by_tokens
groups = TieResolver.rank_by_tokens(war, context, context.participants) groups = TieResolver.rank_by_tokens(war, context, context.participants)
if len(groups[0]) == 1: if len(groups[0]) == 1:
war.events.append( war.events.append(
@ -395,8 +428,10 @@ class TieResolver:
groups[0][0], groups[0][0],
context.context_type, context.context_type,
context.context_id, context.context_id,
context.score_value, participants=context.participants,
context.objective_id, tie_id=tie_id,
score_value=context.score_value,
objective_id=context.objective_id,
) )
) )
return return
@ -427,11 +462,24 @@ class TieResolver:
@staticmethod @staticmethod
def is_tie_resolved(war: War, context: TieContext) -> bool: def is_tie_resolved(war: War, context: TieContext) -> bool:
return any( for ev in war.events:
isinstance(ev, TieResolved) if not isinstance(ev, TieResolved):
and ev.context_type == context.context_type continue
and ev.context_id == context.context_id if ev.context_type != context.context_type:
and ev.score_value == context.score_value continue
and ev.objective_id == context.objective_id if ev.context_id != context.context_id:
for ev in war.events 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

View file

@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from uuid import uuid4 from uuid import uuid4
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List from typing import Any, Dict, List, Set
from warchron.constants import ContextType from warchron.constants import ContextType
from warchron.model.war_event import ( from warchron.model.war_event import (
@ -62,7 +62,7 @@ class War:
if self.is_over: if self.is_over:
raise ForbiddenOperation("Can't set influence token of a closed war.") 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] = [] new_events: List[WarEvent] = []
for ev in self.events: for ev in self.events:
if isinstance(ev, (InfluenceSpent, InfluenceGained)): if isinstance(ev, (InfluenceSpent, InfluenceGained)):
@ -73,11 +73,10 @@ class War:
self.events = new_events self.events = new_events
self.influence_token = new_state self.influence_token = new_state
def reset_tie_break() -> None: def remove_tiebreak_and_not_over() -> None:
new_events: List[WarEvent] = [] new_events: List[WarEvent] = []
for ev in self.events: for ev in self.events:
if isinstance(ev, (TieResolved)): if isinstance(ev, (TieResolved)):
# FIXME cancel TieResolved + TokenSpent
if ev.context_type == ContextType.BATTLE: if ev.context_type == ContextType.BATTLE:
battle = self.get_battle(ev.context_id) battle = self.get_battle(ev.context_id)
campaign = self.get_campaign_by_sector(battle.sector_id) campaign = self.get_campaign_by_sector(battle.sector_id)
@ -86,6 +85,7 @@ class War:
elif ev.context_type == ContextType.CAMPAIGN: elif ev.context_type == ContextType.CAMPAIGN:
campaign = self.get_campaign(ev.context_id) campaign = self.get_campaign(ev.context_id)
campaign.is_over = False campaign.is_over = False
# nothing specific to do with CHOICE (can retrigger pairing)
else: else:
new_events.append(ev) new_events.append(ev)
self.events = new_events self.events = new_events
@ -104,7 +104,7 @@ class War:
"Some influence tokens already exist in this war.\n" "Some influence tokens already exist in this war.\n"
"All tokens will be deleted and tie-breaks will become draw.\n" "All tokens will be deleted and tie-breaks will become draw.\n"
"Do you want to continue?", "Do you want to continue?",
action=cleanup_token_and_tie, action=remove_token_and_draw_tie,
) )
if new_state is True: if new_state is True:
has_tie_resolved = any(isinstance(ev, TieResolved) for ev in self.events) 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" "Some influence tokens and draws exist in this war.\n"
"All influence outcomes and tie-breaks will be reset.\n" "All influence outcomes and tie-breaks will be reset.\n"
"Do you want to continue?", "Do you want to continue?",
action=reset_tie_break, action=remove_tiebreak_and_not_over,
) )
def set_state(self, new_state: bool) -> None: def set_state(self, new_state: bool) -> None:
@ -216,8 +216,8 @@ class War:
# War participant methods # War participant methods
def get_all_war_participants_ids(self) -> set[str]: def get_all_war_participants_ids(self) -> List[str]:
return set(self.participants.keys()) return list(self.participants.keys())
def has_participant(self, participant_id: str) -> bool: def has_participant(self, participant_id: str) -> bool:
return participant_id in self.participants return participant_id in self.participants
@ -288,6 +288,7 @@ class War:
if month is None: if month is None:
month = self.get_default_campaign_values()["month"] month = self.get_default_campaign_values()["month"]
campaign = Campaign(name, month) campaign = Campaign(name, month)
campaign._war = self
self.campaigns.append(campaign) self.campaigns.append(campaign)
return campaign return campaign
@ -550,3 +551,47 @@ class War:
if isinstance(e, InfluenceSpent) and e.participant_id == participant_id if isinstance(e, InfluenceSpent) and e.participant_id == participant_id
) )
return gained - spent 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]

View file

@ -1,5 +1,5 @@
from __future__ import annotations 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 datetime import datetime
from uuid import uuid4 from uuid import uuid4
@ -22,12 +22,14 @@ class WarEvent:
participant_id: str | None, participant_id: str | None,
context_type: str, context_type: str,
context_id: str, context_id: str,
tie_id: str | None = None,
): ):
self.id: str = str(uuid4()) self.id: str = str(uuid4())
self.participant_id: str | None = participant_id self.participant_id: str | None = participant_id
self.context_type = context_type # battle, round, campaign, war self.context_type = context_type # battle, round, campaign, war
self.context_id = context_id self.context_id = context_id
self.timestamp: datetime = datetime.now() self.timestamp: datetime = datetime.now()
self.tie_id = tie_id
def set_id(self, new_id: str) -> None: def set_id(self, new_id: str) -> None:
self.id = new_id self.id = new_id
@ -46,6 +48,7 @@ class WarEvent:
"context_type": self.context_type, "context_type": self.context_type,
"context_id": self.context_id, "context_id": self.context_id,
"timestamp": self.timestamp.isoformat(), "timestamp": self.timestamp.isoformat(),
"tie_id": self.tie_id,
} }
@classmethod @classmethod
@ -55,6 +58,7 @@ class WarEvent:
ev.context_type = data["context_type"] ev.context_type = data["context_type"]
ev.context_id = data["context_id"] ev.context_id = data["context_id"]
ev.timestamp = datetime.fromisoformat(data["timestamp"]) ev.timestamp = datetime.fromisoformat(data["timestamp"])
ev.tie_id = data.get("tie_id")
return ev return ev
@staticmethod @staticmethod
@ -72,14 +76,17 @@ class TieResolved(WarEvent):
def __init__( def __init__(
self, self,
participant_id: str | None, participant_id: str | None, # winner_id or None if confirmed draw
context_type: str, context_type: str,
context_id: str, context_id: str,
participants: List[str],
tie_id: str | None = None, # None if draw without tie-break
score_value: int | None = None, score_value: int | None = None,
objective_id: str | None = None, objective_id: str | None = None,
sector_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.score_value = score_value
self.objective_id = objective_id self.objective_id = objective_id
self.sector_id = sector_id self.sector_id = sector_id
@ -88,6 +95,7 @@ class TieResolved(WarEvent):
d = super().toDict() d = super().toDict()
d.update( d.update(
{ {
"participants": self.participants,
"score_value": self.score_value or None, "score_value": self.score_value or None,
"objective_id": self.objective_id or None, "objective_id": self.objective_id or None,
"sector_id": self.sector_id or None, "sector_id": self.sector_id or None,
@ -101,6 +109,8 @@ class TieResolved(WarEvent):
JsonHelper.none_if_empty(data["participant_id"]), JsonHelper.none_if_empty(data["participant_id"]),
data["context_type"], data["context_type"],
data["context_id"], data["context_id"],
data["participants"],
data["tie_id"],
JsonHelper.none_if_empty(data["score_value"]), JsonHelper.none_if_empty(data["score_value"]),
JsonHelper.none_if_empty(data["objective_id"]), JsonHelper.none_if_empty(data["objective_id"]),
JsonHelper.none_if_empty(data["sector_id"]), JsonHelper.none_if_empty(data["sector_id"]),
@ -156,11 +166,14 @@ class InfluenceSpent(WarEvent):
amount: int, amount: int,
context_type: str, context_type: str,
context_id: str, context_id: str,
tie_id: str,
objective_id: str | 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.amount = amount self.amount = amount
self.objective_id = objective_id self.objective_id = objective_id
self.sector_id = sector_id
def toDict(self) -> Dict[str, Any]: def toDict(self) -> Dict[str, Any]:
d = super().toDict() d = super().toDict()
@ -168,6 +181,7 @@ class InfluenceSpent(WarEvent):
{ {
"amount": self.amount, "amount": self.amount,
"objective_id": self.objective_id, "objective_id": self.objective_id,
"sector_id": self.sector_id or None,
} }
) )
return d return d
@ -179,6 +193,8 @@ class InfluenceSpent(WarEvent):
int(data["amount"]), int(data["amount"]),
data["context_type"], data["context_type"],
data["context_id"], data["context_id"],
data["tie_id"],
JsonHelper.none_if_empty(data["objective_id"]), JsonHelper.none_if_empty(data["objective_id"]),
JsonHelper.none_if_empty(data["sector_id"]),
) )
return cls._base_fromDict(ev, data) return cls._base_fromDict(ev, data)

View file

@ -338,7 +338,12 @@
"participant_id": null, "participant_id": null,
"context_type": "battle", "context_type": "battle",
"context_id": "79accf7c-2d93-4ac3-b747-e7092bfe3feb", "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", "timestamp": "2026-02-26T16:11:44.346337",
"tie_id": null,
"score_value": null, "score_value": null,
"objective_id": null, "objective_id": null,
"sector_id": null "sector_id": null