fix round tie loop + improve tie ranking

This commit is contained in:
Maxime Réaux 2026-02-20 23:44:22 +01:00
parent 60d8e6ca15
commit f339498f97
6 changed files with 99 additions and 110 deletions

View file

@ -96,6 +96,7 @@ class AppController:
self.navigation.refresh_players_view() self.navigation.refresh_players_view()
self.navigation.refresh_wars_view() self.navigation.refresh_wars_view()
self.update_window_title() self.update_window_title()
# TODO refresh details view if wars tab selected
def open_file(self) -> None: def open_file(self) -> None:
if self.is_dirty: if self.is_dirty:
@ -116,6 +117,7 @@ class AppController:
self.navigation.refresh_players_view() self.navigation.refresh_players_view()
self.navigation.refresh_wars_view() self.navigation.refresh_wars_view()
self.update_window_title() self.update_window_title()
# TODO refresh details view if wars tab selected
def save(self) -> None: def save(self) -> None:
if not self.current_file: if not self.current_file:

View file

@ -3,7 +3,6 @@ from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
from warchron.controller.app_controller import AppController from warchron.controller.app_controller import AppController
from warchron.model.war_event import TieResolved
from warchron.model.war import War from warchron.model.war import War
from warchron.model.campaign import Campaign from warchron.model.campaign import Campaign
from warchron.model.round import Round from warchron.model.round import Round
@ -23,22 +22,8 @@ class RoundClosureWorkflow(ClosureWorkflow):
ClosureService.check_round_closable(round) ClosureService.check_round_closable(round)
ties = TieResolver.find_battle_ties(war, round.id) ties = TieResolver.find_battle_ties(war, round.id)
while ties: while ties:
resolvable = [] bids_map = self.app.rounds.resolve_ties(war, ties)
for tie in ties: for tie in ties:
if TieResolver.can_tie_be_resolved(war, tie.participants):
resolvable.append(tie)
else:
war.events.append(
TieResolved(
participant_id=None, # draw confirmed
context_type=tie.context_type,
context_id=tie.context_id,
)
)
if not resolvable:
break
bids_map = self.app.rounds.resolve_ties(war, resolvable)
for tie in resolvable:
bids = bids_map[tie.context_id] bids = bids_map[tie.context_id]
TieResolver.apply_bids( TieResolver.apply_bids(
war, war,
@ -46,11 +31,8 @@ class RoundClosureWorkflow(ClosureWorkflow):
tie.context_id, tie.context_id,
bids, bids,
) )
TieResolver.try_tie_break( TieResolver.resolve_tie_state(
war, war, tie.context_type, tie.context_id, tie.participants, bids
tie.context_type,
tie.context_id,
tie.participants,
) )
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():
@ -64,22 +46,8 @@ class CampaignClosureWorkflow(ClosureWorkflow):
ClosureService.check_campaign_closable(campaign) ClosureService.check_campaign_closable(campaign)
ties = TieResolver.find_campaign_ties(war, campaign.id) ties = TieResolver.find_campaign_ties(war, campaign.id)
while ties: while ties:
resolvable = [] bids_map = self.app.campaigns.resolve_ties(war, ties)
for tie in ties: for tie in ties:
if TieResolver.can_tie_be_resolved(war, tie.participants):
resolvable.append(tie)
else:
war.events.append(
TieResolved(
participant_id=None,
context_type=tie.context_type,
context_id=tie.context_id,
)
)
if not resolvable:
break
bids_map = self.app.campaigns.resolve_ties(war, resolvable)
for tie in resolvable:
bids = bids_map[tie.context_id] bids = bids_map[tie.context_id]
TieResolver.apply_bids( TieResolver.apply_bids(
war, war,
@ -87,7 +55,7 @@ class CampaignClosureWorkflow(ClosureWorkflow):
tie.context_id, tie.context_id,
bids, bids,
) )
TieResolver.try_tie_break( TieResolver.resolve_tie_state(
war, war,
tie.context_type, tie.context_type,
tie.context_id, tie.context_id,

View file

@ -187,6 +187,7 @@ class Campaign:
mission: str | None, mission: str | None,
description: str | None, description: str | None,
) -> None: ) -> None:
# TODO raise error if sector used in a closed round (potential tokens)
if self.is_over: if self.is_over:
raise ForbiddenOperation("Can't update sector in a closed campaign.") raise ForbiddenOperation("Can't update sector in a closed campaign.")
sect = self.get_sector(sector_id) sect = self.get_sector(sector_id)

View file

@ -27,7 +27,8 @@ class ClosureService:
@staticmethod @staticmethod
def apply_battle_outcomes(war: War, campaign: Campaign, battle: Battle) -> None: def apply_battle_outcomes(war: War, campaign: Campaign, battle: Battle) -> None:
already_granted = any( already_granted = any(
isinstance(e, InfluenceGained) and e.source == f"battle:{battle.sector_id}" isinstance(e, InfluenceGained)
and e.context_id == f"battle:{battle.sector_id}"
for e in war.events for e in war.events
) )
if already_granted: if already_granted:
@ -49,7 +50,8 @@ class ClosureService:
InfluenceGained( InfluenceGained(
participant_id=effective_winner, participant_id=effective_winner,
amount=1, amount=1,
source=f"battle:{battle.sector_id}", context_type=ContextType.BATTLE,
context_id=f"battle:{battle.sector_id}",
) )
) )

View file

@ -16,6 +16,14 @@ class TieContext:
participants: List[str] # war_participant_ids participants: List[str] # war_participant_ids
@dataclass
class TieState:
winner: str | None
tied_players: List[str]
eliminated: List[str]
# is_final: bool
class TieResolver: class TieResolver:
@staticmethod @staticmethod
@ -26,20 +34,20 @@ class TieResolver:
for battle in round.battles.values(): for battle in round.battles.values():
if not battle.is_draw(): if not battle.is_draw():
continue continue
resolved = any( if TieResolver.is_tie_resolved(war, ContextType.BATTLE, battle.sector_id):
isinstance(e, TieResolved)
and e.context_type == ContextType.BATTLE
and e.context_id == battle.sector_id
for e in war.events
)
if resolved:
continue continue
if campaign is None: if campaign is None:
raise RuntimeError("No campaign for this battle tie") raise RuntimeError("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 RuntimeError("Missing player(s) in this battle context.") raise RuntimeError("Missing player(s) in this battle context.")
p1 = campaign.participants[battle.player_1_id].war_participant_id p1 = campaign.participants[battle.player_1_id].war_participant_id
p2 = campaign.participants[battle.player_2_id].war_participant_id p2 = campaign.participants[battle.player_2_id].war_participant_id
if not TieResolver.can_tie_be_resolved(war, [p1, p2]):
war.events.append(
TieResolved(None, ContextType.BATTLE, battle.sector_id)
)
continue
ties.append( ties.append(
TieContext( TieContext(
context_type=ContextType.BATTLE, context_type=ContextType.BATTLE,
@ -51,13 +59,7 @@ class TieResolver:
@staticmethod @staticmethod
def find_campaign_ties(war: War, campaign_id: str) -> List[TieContext]: def find_campaign_ties(war: War, campaign_id: str) -> List[TieContext]:
resolved = any( if TieResolver.is_tie_resolved(war, ContextType.CAMPAIGN, campaign_id):
isinstance(e, TieResolved)
and e.context_type == ContextType.CAMPAIGN
and e.context_id == campaign_id
for e in war.events
)
if resolved:
return [] return []
scores = ScoreService.compute_scores(war, ContextType.CAMPAIGN, campaign_id) scores = ScoreService.compute_scores(war, ContextType.CAMPAIGN, campaign_id)
buckets: DefaultDict[int, List[str]] = defaultdict(list) buckets: DefaultDict[int, List[str]] = defaultdict(list)
@ -96,46 +98,54 @@ class TieResolver:
participant_id=war_part_id, participant_id=war_part_id,
amount=1, amount=1,
context_type=context_type, context_type=context_type,
context_id=context_id,
) )
) )
@staticmethod @staticmethod
def try_tie_break( def rank_by_tokens(
war: War, war: War,
context_type: ContextType, context_type: ContextType,
context_id: str, context_id: str,
participants: List[str], # war_participant_ids participants: List[str],
) -> bool: ) -> List[List[str]]:
spent: Dict[str, int] = {} spent = {pid: 0 for pid in participants}
for war_part_id in participants: for ev in war.events:
spent[war_part_id] = sum( if (
e.amount isinstance(ev, InfluenceSpent)
for e in war.events and ev.context_type == context_type
if isinstance(e, InfluenceSpent) and ev.context_id == context_id
and e.participant_id == war_part_id and ev.participant_id in spent
and e.context_type == context_type ):
) spent[ev.participant_id] += ev.amount
values = set(spent.values()) sorted_items = sorted(spent.items(), key=lambda x: x[1], reverse=True)
if values == {0}: # no bid = confirmed draw groups: List[List[str]] = []
war.events.append( current_score = None
TieResolved( for pid, score in sorted_items:
participant_id=None, if score != current_score:
context_type=context_type, groups.append([])
context_id=context_id, current_score = score
) groups[-1].append(pid)
) return groups
return True
if len(values) == 1: # tie again, continue @staticmethod
return False def resolve_tie_state(
winner = max(spent.items(), key=lambda item: item[1])[0] war: War,
war.events.append( context_type: ContextType,
TieResolved( context_id: str,
participant_id=winner, participants: List[str],
context_type=context_type, bids: dict[str, bool] | None = None,
context_id=context_id, ) -> None:
) # confirmed draw if bids are 0
) if bids is not None and not any(bids.values()):
return True war.events.append(TieResolved(None, context_type, context_id))
return
# else rank_by_tokens
groups = TieResolver.rank_by_tokens(war, context_type, context_id, participants)
if len(groups[0]) == 1:
war.events.append(TieResolved(groups[0][0], context_type, context_id))
return
# if tie persists, do nothing, workflow will call again
@staticmethod @staticmethod
def can_tie_be_resolved(war: War, participants: List[str]) -> bool: def can_tie_be_resolved(war: War, participants: List[str]) -> bool:
@ -155,3 +165,12 @@ class TieResolver:
): ):
return ev.participant_id is not None return ev.participant_id is not None
return False return False
@staticmethod
def is_tie_resolved(war: War, context_type: ContextType, context_id: str) -> bool:
return any(
isinstance(ev, TieResolved)
and ev.context_type == context_type
and ev.context_id == context_id
for ev in war.events
)

View file

@ -15,9 +15,11 @@ def register_event(cls: Type[T]) -> Type[T]:
class WarEvent: class WarEvent:
TYPE = "WarEvent" TYPE = "WarEvent"
def __init__(self, participant_id: str | None = None): def __init__(self, participant_id: str | None, context_type: str, context_id: str):
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_id = context_id
self.timestamp: datetime = datetime.now() self.timestamp: datetime = datetime.now()
def set_id(self, new_id: str) -> None: def set_id(self, new_id: str) -> None:
@ -34,6 +36,8 @@ class WarEvent:
"type": self.TYPE, "type": self.TYPE,
"id": self.id, "id": self.id,
"participant_id": self.participant_id, "participant_id": self.participant_id,
"context_type": self.context_type,
"context_id": self.context_id,
"timestamp": self.timestamp.isoformat(), "timestamp": self.timestamp.isoformat(),
} }
@ -41,6 +45,8 @@ class WarEvent:
def _base_fromDict(cls: Type[T], ev: T, data: Dict[str, Any]) -> T: def _base_fromDict(cls: Type[T], ev: T, data: Dict[str, Any]) -> T:
ev.id = data["id"] ev.id = data["id"]
ev.participant_id = data["participant_id"] ev.participant_id = data["participant_id"]
ev.context_type = data["context_type"]
ev.context_id = data["context_id"]
ev.timestamp = datetime.fromisoformat(data["timestamp"]) ev.timestamp = datetime.fromisoformat(data["timestamp"])
return ev return ev
@ -58,21 +64,10 @@ class TieResolved(WarEvent):
TYPE = "TieResolved" TYPE = "TieResolved"
def __init__(self, participant_id: str | None, context_type: str, context_id: str): def __init__(self, participant_id: str | None, context_type: str, context_id: str):
super().__init__(participant_id) super().__init__(participant_id, context_type, context_id)
self.participant_id: str | None = (
participant_id # winner or None (confirmed tie)
)
self.context_type = context_type # battle, round, campaign, war
self.context_id = context_id
def toDict(self) -> Dict[str, Any]: def toDict(self) -> Dict[str, Any]:
d = super().toDict() d = super().toDict()
d.update(
{
"context_type": self.context_type,
"context_id": self.context_id,
}
)
return d return d
@classmethod @classmethod
@ -89,17 +84,17 @@ class TieResolved(WarEvent):
class InfluenceGained(WarEvent): class InfluenceGained(WarEvent):
TYPE = "InfluenceGained" TYPE = "InfluenceGained"
def __init__(self, participant_id: str, amount: int, source: str): def __init__(
super().__init__(participant_id) self, participant_id: str, amount: int, context_type: str, context_id: str
):
super().__init__(participant_id, context_type, context_id)
self.amount = amount self.amount = amount
self.source = source # "battle", "tie_resolution", etc.
def toDict(self) -> Dict[str, Any]: def toDict(self) -> Dict[str, Any]:
d = super().toDict() d = super().toDict()
d.update( d.update(
{ {
"amount": self.amount, "amount": self.amount,
"source": self.source,
} }
) )
return d return d
@ -108,8 +103,9 @@ class InfluenceGained(WarEvent):
def fromDict(cls, data: Dict[str, Any]) -> InfluenceGained: def fromDict(cls, data: Dict[str, Any]) -> InfluenceGained:
ev = cls( ev = cls(
data["participant_id"], data["participant_id"],
data["amount"], int(data["amount"]),
data["source"], data["context_type"],
data["context_id"],
) )
return cls._base_fromDict(ev, data) return cls._base_fromDict(ev, data)
@ -118,17 +114,17 @@ class InfluenceGained(WarEvent):
class InfluenceSpent(WarEvent): class InfluenceSpent(WarEvent):
TYPE = "InfluenceSpent" TYPE = "InfluenceSpent"
def __init__(self, participant_id: str, amount: int, context_type: str): def __init__(
super().__init__(participant_id) self, participant_id: str, amount: int, context_type: str, context_id: str
):
super().__init__(participant_id, context_type, context_id)
self.amount = amount self.amount = amount
self.context_type = context_type # "battle_tie", "campaign_tie", etc.
def toDict(self) -> Dict[str, Any]: def toDict(self) -> Dict[str, Any]:
d = super().toDict() d = super().toDict()
d.update( d.update(
{ {
"amount": self.amount, "amount": self.amount,
"context_type": self.context_type,
} }
) )
return d return d
@ -137,7 +133,8 @@ class InfluenceSpent(WarEvent):
def fromDict(cls, data: Dict[str, Any]) -> InfluenceSpent: def fromDict(cls, data: Dict[str, Any]) -> InfluenceSpent:
ev = cls( ev = cls(
data["participant_id"], data["participant_id"],
data["amount"], int(data["amount"]),
data["context_type"], data["context_type"],
data["context_id"],
) )
return cls._base_fromDict(ev, data) return cls._base_fromDict(ev, data)