choice tie-break and random fallback + exception cleanup + war<->camp pid

This commit is contained in:
Maxime Réaux 2026-03-12 16:28:20 +01:00
parent 241d7f10f5
commit 241e76c937
15 changed files with 241 additions and 157 deletions

View file

@ -111,7 +111,15 @@ class AppController:
path = self.view.ask_open_file() path = self.view.ask_open_file()
if not path: if not path:
return return
self.model.load(path) try:
self.model.load(path)
except RuntimeError as e:
QMessageBox.warning(
self.view,
"Add forbidden",
str(e),
)
return
self.current_file = path self.current_file = path
self.is_dirty = False self.is_dirty = False
self.navigation.refresh_players_view() self.navigation.refresh_players_view()

View file

@ -14,7 +14,7 @@ from warchron.controller.dtos import (
RoundDTO, RoundDTO,
CampaignParticipantScoreDTO, CampaignParticipantScoreDTO,
) )
from warchron.model.exception import ForbiddenOperation, DomainError from warchron.model.exception import AbortedOperation, DomainError
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.campaign_participant import CampaignParticipant from warchron.model.campaign_participant import CampaignParticipant
@ -196,7 +196,7 @@ class CampaignController:
) )
if not dialog.exec(): if not dialog.exec():
TieResolver.cancel_tie_break(war, ctx) TieResolver.cancel_tie_break(war, ctx)
raise ForbiddenOperation("Tie resolution cancelled") raise AbortedOperation("Tie resolution cancelled")
bids_map[ctx.key()] = dialog.get_bids() bids_map[ctx.key()] = dialog.get_bids()
return bids_map return bids_map

View file

@ -104,12 +104,8 @@ class RoundPairingWorkflow:
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(round)
ties = TieResolver.find_choice_ties(war, round.id) Pairing.assign_battles_to_participants(
while ties: war,
bids_map = self.app.rounds.resolve_ties(war, ties) round,
for tie in ties: resolve_ties_callback=self.app.rounds.resolve_ties,
bids = bids_map[tie.key()] )
TieResolver.apply_bids(war, tie, bids)
TieResolver.resolve_tie_state(war, tie, bids)
ties = TieResolver.find_choice_ties(war, round.id)
Pairing.assign_battles_to_participants(war, round)

View file

@ -6,7 +6,7 @@ from PyQt6.QtGui import QIcon
from warchron.constants import ItemType, RefreshScope, Icons, IconName, ContextType from warchron.constants import ItemType, RefreshScope, Icons, IconName, ContextType
from warchron.model.exception import ( from warchron.model.exception import (
ForbiddenOperation, AbortedOperation,
DomainError, DomainError,
RequiresConfirmation, RequiresConfirmation,
) )
@ -119,9 +119,7 @@ class RoundController:
) )
p1_war = None p1_war = None
if battle.player_1_id is not None: if battle.player_1_id is not None:
p1_war = camp.participants[ p1_war = camp.campaign_to_war_part_id(battle.player_1_id)
battle.player_1_id
].war_participant_id
pixmap = Icons.get_pixmap(IconName.TIEBREAK_TOKEN) pixmap = Icons.get_pixmap(IconName.TIEBREAK_TOKEN)
if effective_winner == p1_war: if effective_winner == p1_war:
p1_icon = QIcon(pixmap) p1_icon = QIcon(pixmap)
@ -192,10 +190,19 @@ class RoundController:
workflow = RoundPairingWorkflow(self.app) workflow = RoundPairingWorkflow(self.app)
try: try:
workflow.start(war, rnd) workflow.start(war, rnd)
except AbortedOperation as e:
QMessageBox.warning(
self.app.view,
"Canceled pairing",
str(e),
)
for bat in rnd.battles.values():
bat.cleanup_battle_players()
return
except DomainError as e: except DomainError as e:
QMessageBox.warning( QMessageBox.warning(
self.app.view, self.app.view,
"Closure forbidden", "Pairing impossible",
str(e), str(e),
) )
return return
@ -238,7 +245,7 @@ class RoundController:
) )
if not dialog.exec(): if not dialog.exec():
TieResolver.cancel_tie_break(war, ctx) TieResolver.cancel_tie_break(war, ctx)
raise ForbiddenOperation("Tie resolution cancelled") raise AbortedOperation("Tie resolution cancelled")
bids_map[ctx.key()] = dialog.get_bids() bids_map[ctx.key()] = dialog.get_bids()
return bids_map return bids_map

View file

@ -10,7 +10,7 @@ from warchron.constants import (
) )
from warchron.model.exception import ( from warchron.model.exception import (
DomainError, DomainError,
ForbiddenOperation, AbortedOperation,
RequiresConfirmation, RequiresConfirmation,
) )
@ -190,7 +190,7 @@ class WarController:
) )
if not dialog.exec(): if not dialog.exec():
TieResolver.cancel_tie_break(war, ctx) TieResolver.cancel_tie_break(war, ctx)
raise ForbiddenOperation("Tie resolution cancelled") raise AbortedOperation("Tie resolution cancelled")
bids_map[ctx.key()] = dialog.get_bids() bids_map[ctx.key()] = dialog.get_bids()
return bids_map return bids_map

View file

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Any, Dict, List from typing import Any, Dict, List
from warchron.model.exception import DomainError
from warchron.model.json_helper import JsonHelper from warchron.model.json_helper import JsonHelper
@ -70,7 +71,7 @@ class Battle:
if self.player_2_id is None: if self.player_2_id is None:
self.player_2_id = participant_id self.player_2_id = participant_id
return return
raise RuntimeError("Battle has no available places") raise DomainError("Battle has no available places")
def cleanup_battle_players(self) -> None: def cleanup_battle_players(self) -> None:
self.player_1_id = None self.player_1_id = None

View file

@ -91,14 +91,21 @@ class Campaign:
except KeyError: except KeyError:
raise KeyError(f"Participant {participant_id} not in campaign {self.id}") raise KeyError(f"Participant {participant_id} not in campaign {self.id}")
def get_campaign_participant_by_war_participant_id( def campaign_to_war_part_id(
self, self,
war_participant_id: str, campaign_pid: str,
) -> CampaignParticipant | None: ) -> str:
cp = self.get_campaign_participant(campaign_pid)
return cp.war_participant_id
def war_to_campaign_part_id(
self,
war_pid: str,
) -> str:
for cp in self.participants.values(): for cp in self.participants.values():
if cp.war_participant_id == war_participant_id: if cp.war_participant_id == war_pid:
return cp return cp.id
return None raise KeyError(f"War participant {war_pid} not within campaign participants")
def get_all_campaign_participants(self) -> List[CampaignParticipant]: def get_all_campaign_participants(self) -> List[CampaignParticipant]:
return list(self.participants.values()) return list(self.participants.values())

View file

@ -34,7 +34,7 @@ class ClosureService:
return return
base_winner = None base_winner = None
if battle.winner_id is not None: if battle.winner_id is not None:
base_winner = campaign.participants[battle.winner_id].war_participant_id base_winner = campaign.campaign_to_war_part_id(battle.winner_id)
effective_winner = ResultChecker.get_effective_winner_id( effective_winner = ResultChecker.get_effective_winner_id(
war, war,
ContextType.BATTLE, ContextType.BATTLE,

View file

@ -13,6 +13,12 @@ class ForbiddenOperation(DomainError):
pass pass
class AbortedOperation(DomainError):
"""Generic 'you canceled this' rule."""
pass
class DomainDecision(Exception): class DomainDecision(Exception):
"""Base class for domain actions requiring user decision.""" """Base class for domain actions requiring user decision."""

View file

@ -1,8 +1,8 @@
from __future__ import annotations from __future__ import annotations
from typing import Dict, List from typing import Dict, List, Callable, Tuple
import random import random
from warchron.constants import ContextType from warchron.constants import ContextType, ScoreKind
from warchron.model.exception import ( from warchron.model.exception import (
DomainError, DomainError,
ForbiddenOperation, ForbiddenOperation,
@ -12,6 +12,14 @@ 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.score_service import ScoreService from warchron.model.score_service import ScoreService
from warchron.model.tie_manager import TieResolver, TieContext
from warchron.model.war_event import TieResolved
from warchron.model.score_service import ParticipantScore
ResolveTiesCallback = Callable[
["War", List["TieContext"]],
Dict[Tuple[str, str, int | None], Dict[str, bool]],
]
class Pairing: class Pairing:
@ -30,13 +38,18 @@ class Pairing:
) )
for pid, choice in round.choices.items(): for pid, choice in round.choices.items():
if choice is not None and not choice.priority_sector_id: if choice is not None and not choice.priority_sector_id:
raise DomainError(f"Missing priority choice for participant {pid}") raise ForbiddenOperation(
f"Missing priority choice for participant {pid}"
)
if choice is not None and not choice.secondary_sector_id: if choice is not None and not choice.secondary_sector_id:
raise DomainError(f"Missing secondary choice for participant {pid}") raise ForbiddenOperation(
f"Missing secondary choice for participant {pid}"
)
def cleanup() -> None: def cleanup() -> None:
for bat in round.battles.values(): for bat in round.battles.values():
bat.cleanup_battle_players() bat.cleanup_battle_players()
# FIXME cancel TieResolved + TokenSpent
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
@ -53,6 +66,7 @@ class Pairing:
def assign_battles_to_participants( def assign_battles_to_participants(
war: War, war: War,
round: Round, round: Round,
resolve_ties_callback: ResolveTiesCallback,
) -> None: ) -> None:
campaign = war.get_campaign_by_round(round.id) campaign = war.get_campaign_by_round(round.id)
if campaign is None: if campaign is None:
@ -62,57 +76,162 @@ class Pairing:
ContextType.CAMPAIGN, ContextType.CAMPAIGN,
campaign.id, campaign.id,
) )
score_groups = ScoreService.group_participants_by_score(
scores, lambda score: score.victory_points def value_getter(score: ParticipantScore) -> int:
) return score.victory_points
score_groups = ScoreService.group_participants_by_score(scores, value_getter)
sector_to_battle: Dict[str, Battle] = { sector_to_battle: Dict[str, Battle] = {
b.sector_id: b for b in round.battles.values() b.sector_id: b for b in round.battles.values()
} }
for group in score_groups: for group in score_groups:
# persistent equality → random order score_value = value_getter(scores[group[0]])
ordered_group = list(group) remaining: List[str] = [
random.shuffle(ordered_group) campaign.war_to_campaign_part_id(pid) for pid in group
for participant_id in ordered_group: ]
camp_part = campaign.get_campaign_participant_by_war_participant_id( Pairing._run_phase(
participant_id war,
) round,
if camp_part: remaining,
Pairing._assign_single_participant( sector_to_battle,
round, resolve_ties_callback,
camp_part.id, use_priority=True,
sector_to_battle, score_value=score_value,
) )
Pairing._run_phase(
war,
round,
remaining,
sector_to_battle,
resolve_ties_callback,
use_priority=False,
score_value=score_value,
)
Pairing._assign_fallback(round, remaining)
@staticmethod @staticmethod
def _assign_single_participant( def _run_phase(
war: War,
round: Round, round: Round,
participant_id: str, remaining: List[str],
sector_to_battle: Dict[str, Battle], sector_to_battle: Dict[str, Battle],
resolve_ties_callback: ResolveTiesCallback,
*,
use_priority: bool,
score_value: int,
) -> None: ) -> None:
choice = round.choices.get(participant_id) demand = Pairing._build_sector_demand(
preferred_sectors: List[str] = [] round,
if choice: remaining,
if choice.priority_sector_id: use_priority,
preferred_sectors.append(choice.priority_sector_id) )
if choice.secondary_sector_id: for sector_id, participants in demand.items():
preferred_sectors.append(choice.secondary_sector_id) battle = sector_to_battle.get(sector_id)
# --- try preferred sectors ---
for sect_id in preferred_sectors:
battle = sector_to_battle.get(sect_id)
if not battle: if not battle:
continue continue
if battle.get_available_places(): places = len(battle.get_available_places())
battle.assign_participant(participant_id) if places <= 0:
return continue
# --- fallback rules --- winners = Pairing._resolve_sector_allocation(
available_battles = round.get_battles_with_places() war,
if not available_battles: round,
raise RuntimeError("No available battle remaining") sector_id,
if len(available_battles) == 1: participants,
available_battles[0].assign_participant(participant_id) places,
return resolve_ties_callback,
# multiple remaining battles → warning score_value,
raise RuntimeError( )
f"Ambiguous fallback for participant {participant_id}: " for pid in winners:
"multiple battles still available" battle.assign_participant(pid)
remaining.remove(pid)
@staticmethod
def _build_sector_demand(
round: Round,
participants: List[str],
use_priority: bool,
) -> Dict[str, List[str]]:
demand: Dict[str, List[str]] = {}
for pid in participants:
choice = round.choices.get(pid)
if not choice:
continue
sector_id = (
choice.priority_sector_id
if use_priority
else choice.secondary_sector_id
)
if not sector_id:
continue
demand.setdefault(sector_id, []).append(pid)
return demand
@staticmethod
def _resolve_sector_allocation(
war: War,
round: Round,
sector_id: str,
participants: List[str],
places: int,
resolve_ties_callback: ResolveTiesCallback,
score_value: int,
) -> List[str]:
if len(participants) <= places:
return participants
campaign = war.get_campaign_by_round(round.id)
if campaign is None:
raise DomainError("Campaign not found for round {round.id}")
context = TieContext(
context_type=ContextType.CHOICE,
context_id=round.id,
participants=[
campaign.campaign_to_war_part_id(pid) for pid in participants
],
score_kind=ScoreKind.VP,
sector_id=sector_id,
) )
# ---- resolve tie loop ----
while not TieResolver.is_tie_resolved(war, context):
if not TieResolver.can_tie_be_resolved(war, context, context.participants):
war.events.append(
TieResolved(
None,
context.context_type,
context.context_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)
ranked_groups = TieResolver.rank_by_tokens(
war,
context,
context.participants,
)
ordered: List[str] = []
for group in ranked_groups:
shuffled_group = list(group)
random.shuffle(shuffled_group)
ordered.extend(
campaign.war_to_campaign_part_id(pid) for pid in shuffled_group
)
return ordered[:places]
@staticmethod
def _assign_fallback(
round: Round,
remaining: List[str],
) -> None:
for pid in list(remaining):
available = round.get_battles_with_places()
if not available:
raise DomainError("No available battle remaining")
if len(available) == 1:
available[0].assign_participant(pid)
remaining.remove(pid)
continue
raise DomainError(f"Ambiguous fallback for participant {pid}")

View file

@ -56,9 +56,7 @@ class ScoreService:
if battle.winner_id is not None: if battle.winner_id is not None:
campaign = war.get_campaign_by_campaign_participant(battle.winner_id) campaign = war.get_campaign_by_campaign_participant(battle.winner_id)
if campaign is not None: if campaign is not None:
base_winner = campaign.participants[ base_winner = campaign.campaign_to_war_part_id(battle.winner_id)
battle.winner_id
].war_participant_id
winner = ResultChecker.get_effective_winner_id( winner = ResultChecker.get_effective_winner_id(
war, ContextType.BATTLE, battle.sector_id, base_winner war, ContextType.BATTLE, battle.sector_id, base_winner
) )

View file

@ -3,7 +3,7 @@ from dataclasses import dataclass, field
from collections import defaultdict from collections import defaultdict
from warchron.constants import ContextType, ScoreKind from warchron.constants import ContextType, ScoreKind
from warchron.model.exception import ForbiddenOperation from warchron.model.exception import ForbiddenOperation, DomainError
from warchron.model.war import War from warchron.model.war import War
from warchron.model.war_event import InfluenceSpent, TieResolved from warchron.model.war_event import InfluenceSpent, TieResolved
from warchron.model.score_service import ScoreService, ParticipantScore from warchron.model.score_service import ScoreService, ParticipantScore
@ -17,6 +17,7 @@ class TieContext:
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
def key(self) -> tuple[str, str, int | None]: def key(self) -> tuple[str, str, int | None]:
return (self.context_type, self.context_id, self.score_value) return (self.context_type, self.context_id, self.score_value)
@ -24,71 +25,6 @@ class TieContext:
class TieResolver: class TieResolver:
@staticmethod
def find_choice_ties(
war: War,
round_id: str,
) -> List[TieContext]:
round = war.get_round(round_id)
campaign = war.get_campaign_by_round(round_id)
if campaign is None:
raise RuntimeError("Round without campaign")
ties: List[TieContext] = []
scores = ScoreService.compute_scores(
war,
ContextType.CAMPAIGN,
campaign.id,
)
score_groups = ScoreService.group_participants_by_score(
scores, lambda score: score.victory_points
)
sector_to_battle = {b.sector_id: b for b in round.battles.values()}
for group in score_groups:
if len(group) <= 1:
continue
demand: Dict[str, List[str]] = {}
for pid in group:
choice = round.choices.get(pid)
if not choice:
continue
for sec_id in (
choice.priority_sector_id,
choice.secondary_sector_id,
):
if sec_id:
demand.setdefault(sec_id, []).append(pid)
for sector_id, demanders in demand.items():
battle = sector_to_battle.get(sector_id)
if battle is None:
continue
places = len(battle.get_available_places())
if len(demanders) <= places:
continue
context = TieContext(
ContextType.CHOICE,
round_id,
demanders,
score_value=None,
score_kind=ScoreKind.VP,
)
if TieResolver.is_tie_resolved(war, context):
continue
if not TieResolver.can_tie_be_resolved(
war,
context,
demanders,
):
war.events.append(
TieResolved(
None,
ContextType.CHOICE,
round_id,
)
)
continue
ties.append(context)
return ties
@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)
@ -104,12 +40,12 @@ class TieResolver:
if TieResolver.is_tie_resolved(war, context): if TieResolver.is_tie_resolved(war, context):
continue continue
if campaign is None: if campaign is None:
raise RuntimeError("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 RuntimeError("Missing player(s) in this battle context.") raise DomainError("Missing player(s) in this battle context.")
p1 = campaign.participants[battle.player_1_id].war_participant_id p1_id = campaign.campaign_to_war_part_id(battle.player_1_id)
p2 = campaign.participants[battle.player_2_id].war_participant_id p2_id = campaign.campaign_to_war_part_id(battle.player_2_id)
if not TieResolver.can_tie_be_resolved(war, context, [p1, p2]): 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(None, ContextType.BATTLE, battle.sector_id)
) )
@ -118,7 +54,7 @@ class TieResolver:
TieContext( TieContext(
context_type=ContextType.BATTLE, context_type=ContextType.BATTLE,
context_id=battle.sector_id, context_id=battle.sector_id,
participants=[p1, p2], participants=[p1_id, p2_id],
score_value=None, score_value=None,
score_kind=None, score_kind=None,
) )
@ -240,8 +176,8 @@ class TieResolver:
ContextType.WAR, ContextType.WAR,
war.id, war.id,
[], [],
score_value, score_value=score_value,
ScoreKind.VP, score_kind=ScoreKind.VP,
) )
if TieResolver.is_tie_resolved(war, context): if TieResolver.is_tie_resolved(war, context):
continue continue
@ -427,15 +363,15 @@ class TieResolver:
context, context,
context.participants, context.participants,
) )
# confirmed draw if non had bid # confirmed draw if none had bid
if not active: if not active:
war.events.append( war.events.append(
TieResolved( TieResolved(
None, None,
context.context_type, context.context_type,
context.context_id, context.context_id,
context.score_value, score_value=context.score_value,
context.objective_id, objective_id=context.objective_id,
) )
) )
return return
@ -446,8 +382,8 @@ class TieResolver:
None, None,
context.context_type, context.context_type,
context.context_id, context.context_id,
context.score_value, score_value=context.score_value,
context.objective_id, objective_id=context.objective_id,
) )
) )
return return

View file

@ -77,6 +77,7 @@ class War:
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)

View file

@ -77,10 +77,12 @@ class TieResolved(WarEvent):
context_id: str, context_id: str,
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,
): ):
super().__init__(participant_id, context_type, context_id) super().__init__(participant_id, context_type, context_id)
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
def toDict(self) -> Dict[str, Any]: def toDict(self) -> Dict[str, Any]:
d = super().toDict() d = super().toDict()
@ -88,6 +90,7 @@ class TieResolved(WarEvent):
{ {
"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,
} }
) )
return d return d
@ -100,6 +103,7 @@ class TieResolved(WarEvent):
data["context_id"], data["context_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"]),
) )
return cls._base_fromDict(ev, data) return cls._base_fromDict(ev, data)

View file

@ -340,7 +340,8 @@
"context_id": "79accf7c-2d93-4ac3-b747-e7092bfe3feb", "context_id": "79accf7c-2d93-4ac3-b747-e7092bfe3feb",
"timestamp": "2026-02-26T16:11:44.346337", "timestamp": "2026-02-26T16:11:44.346337",
"score_value": null, "score_value": null,
"objective_id": null "objective_id": null,
"sector_id": null
} }
], ],
"is_over": false "is_over": false