fix tie resolve group order + battle draw token icon

This commit is contained in:
Maxime Réaux 2026-03-20 10:37:35 +01:00
parent 719b0128ed
commit b7a35f6712
5 changed files with 129 additions and 75 deletions

View file

@ -22,6 +22,7 @@ class IconName(StrEnum):
PAIRING = auto() PAIRING = auto()
DRAW = auto() DRAW = auto()
TIEBREAK = auto() TIEBREAK = auto()
DRAWTOKEN = auto()
DELETE = auto() DELETE = auto()
SAVE_AS = auto() SAVE_AS = auto()
SAVE = auto() SAVE = auto()
@ -149,6 +150,11 @@ class Icons:
cls.get_pixmap(IconName.TIEBREAK), cls.get_pixmap(IconName.TIEBREAK),
cls.get_pixmap(IconName.TOKEN), cls.get_pixmap(IconName.TOKEN),
) )
elif name == IconName.DRAWTOKEN:
pix = cls._compose(
cls.get_pixmap(IconName.DRAW),
cls.get_pixmap(IconName.TOKEN),
)
elif name == IconName.WINTOKEN: elif name == IconName.WINTOKEN:
pix = cls._compose( pix = cls._compose(
cls.get_pixmap(IconName.WIN), cls.get_pixmap(IconName.WIN),

View file

@ -24,6 +24,7 @@ from warchron.model.war import War
if TYPE_CHECKING: if TYPE_CHECKING:
from warchron.controller.app_controller import AppController from warchron.controller.app_controller import AppController
from warchron.model.campaign import Campaign
from warchron.controller.dtos import ( from warchron.controller.dtos import (
ParticipantOption, ParticipantOption,
@ -254,13 +255,15 @@ 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]
round: Round | None = None
campaign: Campaign | None = None
if ctx.context_type == ContextType.BATTLE: if ctx.context_type == ContextType.BATTLE:
# context_id = battle.sector_id # context_id corresponds to battle.sector_id
campaign = war.get_campaign_by_sector(ctx.context_id) campaign = war.get_campaign_by_sector(ctx.context_id)
if campaign: if campaign:
round = campaign.get_round_by_battle(ctx.context_id) round = campaign.get_round_by_battle(ctx.context_id)
if ctx.context_type == ContextType.CHOICE: if ctx.context_type == ContextType.CHOICE:
# context_id = round.id # context_id corresponds to round.id
campaign = war.get_campaign_by_round(ctx.context_id) campaign = war.get_campaign_by_round(ctx.context_id)
if campaign: if campaign:
round = war.get_round(ctx.context_id) round = war.get_round(ctx.context_id)

View file

@ -1,5 +1,4 @@
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
@ -23,13 +22,12 @@ class RoundClosureWorkflow(Workflow):
Closer.check_round_closable(round) Closer.check_round_closable(round)
ties = TieBreaker.find_battle_ties(war, round.id) ties = TieBreaker.find_battle_ties(war, round.id)
while ties: while ties:
bids_map = self.app.rounds.resolve_ties(war, ties)
for tie in ties: for tie in ties:
bids = bids_map[tie.key()] TieBreaker.resolve_group(
tie_id = TieBreaker.find_active_tie_id(war, tie) or str(uuid4()) war,
# TODO finish tiebreak by group (like choice) not by turn tie,
TieBreaker.apply_bids(war, tie, tie_id, bids) self.app.rounds.resolve_ties,
TieBreaker.resolve_tie_state(war, tie, tie_id, bids) )
ties = TieBreaker.find_battle_ties(war, round.id) ties = TieBreaker.find_battle_ties(war, round.id)
for battle in round.battles.values(): for battle in round.battles.values():
Closer.apply_battle_outcomes(war, campaign, battle) Closer.apply_battle_outcomes(war, campaign, battle)
@ -42,13 +40,12 @@ class CampaignClosureWorkflow(Workflow):
Closer.check_campaign_closable(campaign) Closer.check_campaign_closable(campaign)
ties = TieBreaker.find_campaign_ties(war, campaign.id) ties = TieBreaker.find_campaign_ties(war, campaign.id)
while ties: while ties:
bids_map = self.app.campaigns.resolve_ties(war, ties)
for tie in ties: for tie in ties:
bids = bids_map[tie.key()] TieBreaker.resolve_group(
tie_id = TieBreaker.find_active_tie_id(war, tie) or str(uuid4()) war,
# TODO finish tiebreak by group (like choice) not by turn tie,
TieBreaker.apply_bids(war, tie, tie_id, bids) self.app.rounds.resolve_ties,
TieBreaker.resolve_tie_state(war, tie, tie_id, bids) )
ties = TieBreaker.find_campaign_ties(war, campaign.id) ties = TieBreaker.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
@ -58,13 +55,12 @@ class CampaignClosureWorkflow(Workflow):
objective_id, objective_id,
) )
while ties: while ties:
bids_map = self.app.campaigns.resolve_ties(war, ties)
for tie in ties: for tie in ties:
bids = bids_map[tie.key()] TieBreaker.resolve_group(
tie_id = TieBreaker.find_active_tie_id(war, tie) or str(uuid4()) war,
# TODO finish tiebreak by group (like choice) not by turn tie,
TieBreaker.apply_bids(war, tie, tie_id, bids) self.app.rounds.resolve_ties,
TieBreaker.resolve_tie_state(war, tie, tie_id, bids) )
ties = TieBreaker.find_campaign_objective_ties( ties = TieBreaker.find_campaign_objective_ties(
war, war,
campaign.id, campaign.id,
@ -79,13 +75,12 @@ class WarClosureWorkflow(Workflow):
Closer.check_war_closable(war) Closer.check_war_closable(war)
ties = TieBreaker.find_war_ties(war) ties = TieBreaker.find_war_ties(war)
while ties: while ties:
bids_map = self.app.wars.resolve_ties(war, ties)
for tie in ties: for tie in ties:
bids = bids_map[tie.key()] TieBreaker.resolve_group(
tie_id = TieBreaker.find_active_tie_id(war, tie) or str(uuid4()) war,
# TODO finish tiebreak by group (like choice) not by turn tie,
TieBreaker.apply_bids(war, tie, tie_id, bids) self.app.rounds.resolve_ties,
TieBreaker.resolve_tie_state(war, tie, tie_id, bids) )
ties = TieBreaker.find_war_ties(war) ties = TieBreaker.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
@ -94,13 +89,12 @@ class WarClosureWorkflow(Workflow):
objective_id, objective_id,
) )
while ties: while ties:
bids_map = self.app.wars.resolve_ties(war, ties)
for tie in ties: for tie in ties:
bids = bids_map[tie.key()] TieBreaker.resolve_group(
tie_id = TieBreaker.find_active_tie_id(war, tie) or str(uuid4()) war,
# TODO finish tiebreak by group (like choice) not by turn tie,
TieBreaker.apply_bids(war, tie, tie_id, bids) self.app.rounds.resolve_ties,
TieBreaker.resolve_tie_state(war, tie, tie_id, bids) )
ties = TieBreaker.find_war_objective_ties( ties = TieBreaker.find_war_objective_ties(
war, war,
objective_id, objective_id,

View file

@ -1,5 +1,5 @@
from __future__ import annotations from __future__ import annotations
from typing import Dict, List, Callable, Tuple from typing import Dict, List
from dataclasses import dataclass from dataclasses import dataclass
from uuid import uuid4 from uuid import uuid4
@ -15,15 +15,10 @@ from warchron.model.war import War
from warchron.model.round import Round from warchron.model.round import Round
from warchron.model.battle import Battle from warchron.model.battle import Battle
from warchron.model.scoring import ScoreComputer from warchron.model.scoring import ScoreComputer
from warchron.model.tiebreaking import TieBreaker, TieContext from warchron.model.tiebreaking import TieBreaker, TieContext, ResolveTiesCallback
from warchron.model.war_event import TieResolved from warchron.model.war_event import TieResolved
from warchron.model.scoring import ParticipantScore from warchron.model.scoring import ParticipantScore
ResolveTiesCallback = Callable[
["War", List["TieContext"]],
Dict[Tuple[str, str, int | None, str | None, str | None], Dict[str, bool]],
]
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class AllocationResult: class AllocationResult:

View file

@ -1,6 +1,7 @@
from typing import List, Dict, DefaultDict, Tuple from __future__ import annotations
from typing import List, Dict, Tuple, Callable, TypeAlias
from dataclasses import dataclass from dataclasses import dataclass
from collections import defaultdict from uuid import uuid4
from warchron.constants import ContextType, ScoreKind from warchron.constants import ContextType, ScoreKind
from warchron.model.exception import ForbiddenOperation, DomainError from warchron.model.exception import ForbiddenOperation, DomainError
@ -29,6 +30,12 @@ class TieContext:
) )
ResolveTiesCallback: TypeAlias = Callable[
[War, List[TieContext]],
Dict[Tuple[str, str, int | None, str | None, str | None], Dict[str, bool]],
]
class TieBreaker: class TieBreaker:
@staticmethod @staticmethod
@ -92,14 +99,22 @@ class TieBreaker:
@staticmethod @staticmethod
def find_campaign_ties(war: War, campaign_id: str) -> List[TieContext]: def find_campaign_ties(war: War, campaign_id: str) -> List[TieContext]:
from warchron.model.checking import ResultChecker
scores = ScoreComputer.compute_scores(war, ContextType.CAMPAIGN, campaign_id) scores = ScoreComputer.compute_scores(war, ContextType.CAMPAIGN, campaign_id)
buckets: DefaultDict[int, List[str]] = defaultdict(list) ranking = ResultChecker.get_effective_ranking(
for pid, score in scores.items(): war,
buckets[score.victory_points].append(pid) ContextType.CAMPAIGN,
campaign_id,
ScoreKind.VP,
scores,
lambda s: s.victory_points,
)
ties: List[TieContext] = [] ties: List[TieContext] = []
for score_value, participants in buckets.items(): for _, group, _ in ranking:
if len(participants) <= 1: if len(group) <= 1:
continue continue
score_value = scores[group[0]].victory_points
context: TieContext = TieContext( context: TieContext = TieContext(
ContextType.CAMPAIGN, ContextType.CAMPAIGN,
campaign_id, campaign_id,
@ -110,13 +125,13 @@ class TieBreaker:
if TieBreaker.is_tie_resolved(war, context): if TieBreaker.is_tie_resolved(war, context):
continue continue
tie_id = TieBreaker.find_active_tie_id(war, context) tie_id = TieBreaker.find_active_tie_id(war, context)
if not TieBreaker.can_tie_be_resolved(war, context, participants): if not TieBreaker.can_tie_be_resolved(war, context, group):
war.events.append( war.events.append(
TieResolved( TieResolved(
participant_id=None, participant_id=None,
context_type=ContextType.CAMPAIGN, context_type=ContextType.CAMPAIGN,
context_id=campaign_id, context_id=campaign_id,
participants=participants, participants=group,
tie_id=tie_id, tie_id=tie_id,
score_value=score_value, score_value=score_value,
) )
@ -126,7 +141,7 @@ class TieBreaker:
TieContext( TieContext(
context_type=ContextType.CAMPAIGN, context_type=ContextType.CAMPAIGN,
context_id=campaign_id, context_id=campaign_id,
participants=participants, participants=group,
score_value=score_value, score_value=score_value,
score_kind=ScoreKind.VP, score_kind=ScoreKind.VP,
) )
@ -137,20 +152,32 @@ class TieBreaker:
def find_campaign_objective_ties( def find_campaign_objective_ties(
war: War, campaign_id: str, objective_id: str war: War, campaign_id: str, objective_id: str
) -> List[TieContext]: ) -> List[TieContext]:
from warchron.model.checking import ResultChecker
scores = ScoreComputer.compute_scores( scores = ScoreComputer.compute_scores(
war, war,
ContextType.CAMPAIGN, ContextType.CAMPAIGN,
campaign_id, campaign_id,
) )
buckets: DefaultDict[int, List[str]] = defaultdict(list)
for pid, score in scores.items(): def value_getter(score: ParticipantScore) -> int:
np_value = score.narrative_points.get(objective_id, 0) return score.narrative_points.get(objective_id, 0)
buckets[np_value].append(pid)
ranking = ResultChecker.get_effective_ranking(
war,
ContextType.CAMPAIGN,
campaign_id,
ScoreKind.NP,
scores,
value_getter,
objective_id,
)
ties: List[TieContext] = [] ties: List[TieContext] = []
context_id = campaign_id context_id = campaign_id
for np_value, participants in buckets.items(): for _, group, _ in ranking:
if len(participants) <= 1: if len(group) <= 1:
continue continue
np_value = value_getter(scores[group[0]])
context: TieContext = TieContext( context: TieContext = TieContext(
ContextType.CAMPAIGN, ContextType.CAMPAIGN,
campaign_id, campaign_id,
@ -165,14 +192,14 @@ class TieBreaker:
if not TieBreaker.can_tie_be_resolved( if not TieBreaker.can_tie_be_resolved(
war, war,
context, context,
participants, group,
): ):
war.events.append( war.events.append(
TieResolved( TieResolved(
participant_id=None, participant_id=None,
context_type=ContextType.CAMPAIGN, context_type=ContextType.CAMPAIGN,
context_id=context_id, context_id=context_id,
participants=participants, participants=group,
tie_id=tie_id, tie_id=tie_id,
score_value=np_value, score_value=np_value,
objective_id=objective_id, objective_id=objective_id,
@ -183,7 +210,7 @@ class TieBreaker:
TieContext( TieContext(
context_type=ContextType.CAMPAIGN, context_type=ContextType.CAMPAIGN,
context_id=context_id, context_id=context_id,
participants=participants, participants=group,
score_value=np_value, score_value=np_value,
score_kind=ScoreKind.NP, score_kind=ScoreKind.NP,
objective_id=objective_id, objective_id=objective_id,
@ -307,6 +334,51 @@ class TieBreaker:
) )
return ties return ties
@staticmethod
def resolve_group(
war: War,
context: TieContext,
resolve_ties_callback: ResolveTiesCallback,
) -> None:
tie_id = TieBreaker.find_active_tie_id(war, context) or str(uuid4())
while not TieBreaker.is_tie_resolved(war, context):
active = TieBreaker.get_active_participants(
war,
context,
context.participants,
)
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,
objective_id=context.objective_id,
sector_id=context.sector_id,
)
if not TieBreaker.can_tie_be_resolved(
war,
context,
current_context.participants,
):
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,
sector_id=context.sector_id,
)
)
return
bids_map = resolve_ties_callback(war, [current_context])
bids = bids_map[current_context.key()]
TieBreaker.apply_bids(war, context, tie_id, bids)
TieBreaker.resolve_tie_state(war, context, tie_id, bids)
@staticmethod @staticmethod
def apply_bids( def apply_bids(
war: War, war: War,
@ -447,22 +519,6 @@ class TieBreaker:
active = TieBreaker.get_active_participants(war, context, participants) active = TieBreaker.get_active_participants(war, context, participants)
return any(war.get_influence_tokens(pid) > 0 for pid in active) return any(war.get_influence_tokens(pid) > 0 for pid in active)
@staticmethod
def was_tie_broken_by_tokens(
war: War,
context: TieContext,
) -> bool:
for ev in reversed(war.events):
if (
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
):
return ev.participant_id is not None
return False
@staticmethod @staticmethod
def is_tie_resolved(war: War, context: TieContext) -> bool: def is_tie_resolved(war: War, context: TieContext) -> bool:
for ev in war.events: for ev in war.events: