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(
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)

View file

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

View file

@ -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():

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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