detect campaign tie

This commit is contained in:
Maxime Réaux 2026-02-20 11:01:25 +01:00
parent 7c9c941864
commit 60d8e6ca15
9 changed files with 203 additions and 116 deletions

View file

@ -3,7 +3,7 @@ from typing import List
from warchron.constants import ContextType
from warchron.model.exception import ForbiddenOperation
from warchron.model.tie_manager import TieResolver
from warchron.model.result_checker import ResultChecker
from warchron.model.war_event import InfluenceGained
from warchron.model.war import War
from warchron.model.campaign import Campaign
@ -35,7 +35,7 @@ class ClosureService:
base_winner = None
if battle.winner_id is not None:
base_winner = campaign.participants[battle.winner_id].war_participant_id
effective_winner = TieResolver.get_effective_winner_id(
effective_winner = ResultChecker.get_effective_winner_id(
war,
ContextType.BATTLE,
battle.sector_id,
@ -60,28 +60,17 @@ class ClosureService:
# Campaign methods
@staticmethod
def close_campaign(campaign: Campaign) -> List[str]:
def check_campaign_closable(campaign: Campaign) -> None:
if campaign.is_over:
raise ForbiddenOperation("Campaign already closed")
if not campaign.all_rounds_finished():
raise RuntimeError("All rounds must be finished to close their campaign")
ties: List[str] = []
# for round in campaign.rounds:
# # compute score
# # if participants have same score
# ties.append(
# ResolutionContext(
# context_type=ContextType.CAMPAIGN,
# context_id=campaign.id,
# participant_ids=[
# # TODO ref to War.participants at some point
# campaign.participants[campaign_participant_id],
# campaign.participants[campaign_participant_id],
# ],
# )
# )
if ties:
return ties
raise ForbiddenOperation(
"All rounds must be closed to close their campaign"
)
@staticmethod
def finalize_campaign(campaign: Campaign) -> None:
campaign.is_over = True
return []
# War methods

View file

@ -0,0 +1,24 @@
from warchron.constants import ContextType
from warchron.model.war import War
from warchron.model.war_event import TieResolved
class ResultChecker:
@staticmethod
def get_effective_winner_id(
war: War,
context_type: ContextType,
context_id: str,
base_winner_id: str | None,
) -> str | None:
if base_winner_id is not None:
return base_winner_id
for ev in reversed(war.events):
if (
isinstance(ev, TieResolved)
and ev.context_type == context_type
and ev.context_id == context_id
):
return ev.participant_id # None if confirmed draw
return None

View file

@ -1,8 +1,8 @@
from typing import Dict, Iterator
from dataclasses import dataclass, field
from warchron.model.result_checker import ResultChecker
from warchron.constants import ContextType
from warchron.model.tie_manager import TieResolver
from warchron.model.war import War
from warchron.model.battle import Battle
@ -59,7 +59,7 @@ class ScoreService:
base_winner = campaign.participants[
battle.winner_id
].war_participant_id
winner = TieResolver.get_effective_winner_id(
winner = ResultChecker.get_effective_winner_id(
war, ContextType.BATTLE, battle.sector_id, base_winner
)
if winner is None:

View file

@ -1,17 +1,27 @@
from typing import List, Dict
from typing import List, Dict, DefaultDict
from dataclasses import dataclass
from collections import defaultdict
from warchron.constants import ContextType
from warchron.model.exception import ForbiddenOperation
from warchron.model.war import War
from warchron.model.round import Round
from warchron.model.battle import Battle
from warchron.model.war_event import InfluenceSpent, TieResolved
from warchron.model.score_service import ScoreService
@dataclass
class TieContext:
context_type: ContextType
context_id: str
participants: List[str] # war_participant_ids
class TieResolver:
@staticmethod
def find_round_ties(round: Round, war: War) -> List[Battle]:
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():
@ -22,10 +32,53 @@ class TieResolver:
and e.context_id == battle.sector_id
for e in war.events
)
if not resolved:
ties.append(battle)
if resolved:
continue
if campaign is None:
raise RuntimeError("No campaign for this battle tie")
if battle.player_1_id is None or battle.player_2_id is None:
raise RuntimeError("Missing player(s) in this battle context.")
p1 = campaign.participants[battle.player_1_id].war_participant_id
p2 = campaign.participants[battle.player_2_id].war_participant_id
ties.append(
TieContext(
context_type=ContextType.BATTLE,
context_id=battle.sector_id,
participants=[p1, p2],
)
)
return ties
@staticmethod
def find_campaign_ties(war: War, campaign_id: str) -> List[TieContext]:
resolved = any(
isinstance(e, TieResolved)
and e.context_type == ContextType.CAMPAIGN
and e.context_id == campaign_id
for e in war.events
)
if resolved:
return []
scores = ScoreService.compute_scores(war, ContextType.CAMPAIGN, campaign_id)
buckets: DefaultDict[int, List[str]] = defaultdict(list)
for pid, score in scores.items():
buckets[score.victory_points].append(pid)
ties = []
for participants in buckets.values():
if len(participants) > 1:
ties.append(
TieContext(
context_type=ContextType.CAMPAIGN,
context_id=campaign_id,
participants=participants,
)
)
return ties
@staticmethod
def find_war_ties(war: War) -> List[TieContext]:
return [] # TODO
@staticmethod
def apply_bids(
war: War,
@ -88,25 +141,6 @@ class TieResolver:
def can_tie_be_resolved(war: War, participants: List[str]) -> bool:
return any(war.get_influence_tokens(pid) > 0 for pid in participants)
@staticmethod
def get_effective_winner_id(
war: War,
context_type: ContextType,
context_id: str,
base_winner_id: str | None,
) -> str | None:
if base_winner_id is not None:
return base_winner_id
for ev in reversed(war.events):
if (
isinstance(ev, TieResolved)
and ev.context_type == context_type
and ev.context_id == context_id
):
return ev.participant_id # None if confirmed draw
return None
@staticmethod
def was_tie_broken_by_tokens(
war: War,

View file

@ -371,6 +371,14 @@ class War:
# Round methods
# TODO replace multiloops by internal has_* method
def get_round(self, round_id: str) -> Round:
for camp in self.campaigns:
for rnd in camp.rounds:
if rnd.id == round_id:
return rnd
raise KeyError("Round not found")
def add_round(self, campaign_id: str) -> Round:
camp = self.get_campaign(campaign_id)
return camp.add_round()
@ -420,7 +428,7 @@ class War:
for bat in rnd.battles.values():
if bat.sector_id == battle_id:
return bat
raise KeyError("Round not found")
raise KeyError("Battle not found")
def create_battle(self, round_id: str, sector_id: str) -> Battle:
camp = self.get_campaign_by_round(round_id)