resolve pairing WIP

This commit is contained in:
Maxime Réaux 2026-03-11 11:44:57 +01:00
parent 0c6014e946
commit 241d7f10f5
11 changed files with 302 additions and 11 deletions

View file

@ -58,6 +58,7 @@ class AppController:
) )
self.view.endCampaignBtn.clicked.connect(self.campaigns.close_campaign) self.view.endCampaignBtn.clicked.connect(self.campaigns.close_campaign)
self.view.endRoundBtn.clicked.connect(self.rounds.close_round) self.view.endRoundBtn.clicked.connect(self.rounds.close_round)
self.view.resolvePairingBtn.clicked.connect(self.rounds.resolve_pairing)
self.view.on_add_item = self.add_item self.view.on_add_item = self.add_item
self.view.on_edit_item = self.edit_item self.view.on_edit_item = self.edit_item
self.view.on_delete_item = self.delete_item self.view.on_delete_item = self.delete_item

View file

@ -7,6 +7,7 @@ from warchron.model.campaign import Campaign
from warchron.model.round import Round from warchron.model.round import Round
from warchron.model.closure_service import ClosureService from warchron.model.closure_service import ClosureService
from warchron.model.tie_manager import TieResolver from warchron.model.tie_manager import TieResolver
from warchron.model.pairing import Pairing
class ClosureWorkflow: class ClosureWorkflow:
@ -94,3 +95,21 @@ class WarClosureWorkflow(ClosureWorkflow):
objective_id, objective_id,
) )
ClosureService.finalize_war(war) ClosureService.finalize_war(war)
class RoundPairingWorkflow:
def __init__(self, controller: "AppController"):
self.app = controller
def start(self, war: War, round: Round) -> None:
Pairing.check_round_pairable(round)
ties = TieResolver.find_choice_ties(war, round.id)
while ties:
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)
ties = TieResolver.find_choice_ties(war, round.id)
Pairing.assign_battles_to_participants(war, round)

View file

@ -5,7 +5,11 @@ from PyQt6.QtWidgets import QMessageBox
from PyQt6.QtGui import QIcon 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 ForbiddenOperation, DomainError from warchron.model.exception import (
ForbiddenOperation,
DomainError,
RequiresConfirmation,
)
from warchron.model.tie_manager import TieResolver, TieContext from warchron.model.tie_manager import TieResolver, TieContext
from warchron.model.result_checker import ResultChecker from warchron.model.result_checker import ResultChecker
from warchron.model.round import Round from warchron.model.round import Round
@ -20,7 +24,10 @@ from warchron.controller.dtos import (
ChoiceDTO, ChoiceDTO,
BattleDTO, BattleDTO,
) )
from warchron.controller.closure_workflow import RoundClosureWorkflow from warchron.controller.closure_workflow import (
RoundClosureWorkflow,
RoundPairingWorkflow,
)
from warchron.view.choice_dialog import ChoiceDialog from warchron.view.choice_dialog import ChoiceDialog
from warchron.view.battle_dialog import BattleDialog from warchron.view.battle_dialog import BattleDialog
from warchron.view.tie_dialog import TieDialog from warchron.view.tie_dialog import TieDialog
@ -176,6 +183,39 @@ class RoundController:
RefreshScope.WARS_TREE, item_type=ItemType.ROUND, item_id=round_id RefreshScope.WARS_TREE, item_type=ItemType.ROUND, item_id=round_id
) )
def resolve_pairing(self) -> None:
round_id = self.app.navigation.selected_round_id
if not round_id:
return
rnd = self.app.model.get_round(round_id)
war = self.app.model.get_war_by_round(round_id)
workflow = RoundPairingWorkflow(self.app)
try:
workflow.start(war, rnd)
except DomainError as e:
QMessageBox.warning(
self.app.view,
"Closure forbidden",
str(e),
)
return
except RequiresConfirmation as e:
reply = QMessageBox.question(
self.app.view,
"Confirm pairing",
str(e),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply == QMessageBox.StandardButton.Yes:
e.action()
else:
return
self.app.is_dirty = True
self.app.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS)
self.app.navigation.refresh_and_select(
RefreshScope.WARS_TREE, item_type=ItemType.ROUND, item_id=round_id
)
def resolve_ties( def resolve_ties(
self, war: War, contexts: List[TieContext] self, war: War, contexts: List[TieContext]
) -> Dict[tuple[str, str, int | None], Dict[str, bool]]: ) -> Dict[tuple[str, str, int | None], Dict[str, bool]]:

View file

@ -1,5 +1,5 @@
from __future__ import annotations from __future__ import annotations
from typing import Any, Dict from typing import Any, Dict, List
from warchron.model.json_helper import JsonHelper from warchron.model.json_helper import JsonHelper
@ -55,6 +55,27 @@ class Battle:
return True return True
return False return False
def get_available_places(self) -> List[str]:
places: list[str] = []
if self.player_1_id is None:
places.append("player_1")
if self.player_2_id is None:
places.append("player_2")
return places
def assign_participant(self, participant_id: str) -> None:
if self.player_1_id is None:
self.player_1_id = participant_id
return
if self.player_2_id is None:
self.player_2_id = participant_id
return
raise RuntimeError("Battle has no available places")
def cleanup_battle_players(self) -> None:
self.player_1_id = None
self.player_2_id = None
def is_finished(self) -> bool: def is_finished(self) -> bool:
return self.winner_id is not None or self.is_draw() return self.winner_id is not None or self.is_draw()

View file

@ -91,6 +91,15 @@ 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(
self,
war_participant_id: str,
) -> CampaignParticipant | None:
for cp in self.participants.values():
if cp.war_participant_id == war_participant_id:
return cp
return None
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

@ -0,0 +1,118 @@
from __future__ import annotations
from typing import Dict, List
import random
from warchron.constants import ContextType
from warchron.model.exception import (
DomainError,
ForbiddenOperation,
RequiresConfirmation,
)
from warchron.model.war import War
from warchron.model.round import Round
from warchron.model.battle import Battle
from warchron.model.score_service import ScoreService
class Pairing:
@staticmethod
def check_round_pairable(
round: Round,
) -> None:
if round.is_over:
raise ForbiddenOperation("Can not resolve pairing on finished round")
if round.has_finished_battle():
raise ForbiddenOperation("Can not resolve pairing with finished battle(s)")
if len(round.battles) * 2 < len(round.choices):
raise DomainError(
"There are not enough sectors for all participants to battle"
)
for pid, choice in round.choices.items():
if choice is not None and not choice.priority_sector_id:
raise DomainError(f"Missing priority choice for participant {pid}")
if choice is not None and not choice.secondary_sector_id:
raise DomainError(f"Missing secondary choice for participant {pid}")
def cleanup() -> None:
for bat in round.battles.values():
bat.cleanup_battle_players()
if any(
bat.player_1_id is not None or bat.player_2_id is not None
for bat in round.battles.values()
):
raise RequiresConfirmation(
"Battle(s) already have player(s) assigned for this round.\n"
"Battle players will be cleared.\n"
"Do you want to continue?",
action=cleanup,
)
@staticmethod
def assign_battles_to_participants(
war: War,
round: Round,
) -> None:
campaign = war.get_campaign_by_round(round.id)
if campaign is None:
raise DomainError(f"Campaign for round {round.id} doesn't exist")
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: Dict[str, Battle] = {
b.sector_id: b for b in round.battles.values()
}
for group in score_groups:
# persistent equality → random order
ordered_group = list(group)
random.shuffle(ordered_group)
for participant_id in ordered_group:
camp_part = campaign.get_campaign_participant_by_war_participant_id(
participant_id
)
if camp_part:
Pairing._assign_single_participant(
round,
camp_part.id,
sector_to_battle,
)
@staticmethod
def _assign_single_participant(
round: Round,
participant_id: str,
sector_to_battle: Dict[str, Battle],
) -> None:
choice = round.choices.get(participant_id)
preferred_sectors: List[str] = []
if choice:
if choice.priority_sector_id:
preferred_sectors.append(choice.priority_sector_id)
if choice.secondary_sector_id:
preferred_sectors.append(choice.secondary_sector_id)
# --- try preferred sectors ---
for sect_id in preferred_sectors:
battle = sector_to_battle.get(sect_id)
if not battle:
continue
if battle.get_available_places():
battle.assign_participant(participant_id)
return
# --- fallback rules ---
available_battles = round.get_battles_with_places()
if not available_battles:
raise RuntimeError("No available battle remaining")
if len(available_battles) == 1:
available_battles[0].assign_participant(participant_id)
return
# multiple remaining battles → warning
raise RuntimeError(
f"Ambiguous fallback for participant {participant_id}: "
"multiple battles still available"
)

View file

@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from uuid import uuid4 from uuid import uuid4
from typing import Any, Dict from typing import Any, Dict, List
from warchron.model.exception import ForbiddenOperation from warchron.model.exception import ForbiddenOperation
from warchron.model.choice import Choice from warchron.model.choice import Choice
@ -119,6 +119,11 @@ class Round:
b.winner_id is not None or b.is_draw() for b in self.battles.values() b.winner_id is not None or b.is_draw() for b in self.battles.values()
) )
def get_battles_with_places(self) -> List[Battle]:
return [
battle for battle in self.battles.values() if battle.get_available_places()
]
def create_battle(self, sector_id: str) -> Battle: def create_battle(self, sector_id: str) -> Battle:
if self.is_over: if self.is_over:
raise ForbiddenOperation("Can't create battle in a closed round.") raise ForbiddenOperation("Can't create battle in a closed round.")

View file

@ -1,5 +1,6 @@
from typing import Dict, Iterator from typing import Dict, Iterator, List, Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
from collections import defaultdict
from warchron.constants import ContextType from warchron.constants import ContextType
from warchron.model.war import War from warchron.model.war import War
@ -74,3 +75,15 @@ class ScoreService:
sector.minor_objective_id sector.minor_objective_id
] += war.minor_value ] += war.minor_value
return scores return scores
@staticmethod
def group_participants_by_score(
scores: Dict[str, ParticipantScore],
value_getter: Callable[[ParticipantScore], int],
) -> List[List[str]]:
groups: Dict[int, List[str]] = defaultdict(list)
for pid, score in scores.items():
value = value_getter(score)
groups[value].append(pid)
ordered_values = sorted(groups.keys(), reverse=True)
return [groups[value] for value in ordered_values]

View file

@ -24,6 +24,71 @@ 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)

View file

@ -379,7 +379,7 @@ class Ui_MainWindow(object):
spacerItem13 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) spacerItem13 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum)
self.horizontalLayout_13.addItem(spacerItem13) self.horizontalLayout_13.addItem(spacerItem13)
self.resolvePairingBtn = QtWidgets.QPushButton(parent=self.pageRound) self.resolvePairingBtn = QtWidgets.QPushButton(parent=self.pageRound)
self.resolvePairingBtn.setEnabled(False) self.resolvePairingBtn.setEnabled(True)
self.resolvePairingBtn.setObjectName("resolvePairingBtn") self.resolvePairingBtn.setObjectName("resolvePairingBtn")
self.horizontalLayout_13.addWidget(self.resolvePairingBtn) self.horizontalLayout_13.addWidget(self.resolvePairingBtn)
self.verticalLayout_8.addLayout(self.horizontalLayout_13) self.verticalLayout_8.addLayout(self.horizontalLayout_13)
@ -426,7 +426,7 @@ class Ui_MainWindow(object):
self.gridLayout_2.addWidget(self.tabWidget, 0, 0, 1, 1) self.gridLayout_2.addWidget(self.tabWidget, 0, 0, 1, 1)
MainWindow.setCentralWidget(self.centralwidget) MainWindow.setCentralWidget(self.centralwidget)
self.menubar = QtWidgets.QMenuBar(parent=MainWindow) self.menubar = QtWidgets.QMenuBar(parent=MainWindow)
self.menubar.setGeometry(QtCore.QRect(0, 0, 849, 21)) self.menubar.setGeometry(QtCore.QRect(0, 0, 849, 22))
self.menubar.setObjectName("menubar") self.menubar.setObjectName("menubar")
self.menuFile = QtWidgets.QMenu(parent=self.menubar) self.menuFile = QtWidgets.QMenu(parent=self.menubar)
self.menuFile.setObjectName("menuFile") self.menuFile.setObjectName("menuFile")
@ -476,7 +476,7 @@ class Ui_MainWindow(object):
self.retranslateUi(MainWindow) self.retranslateUi(MainWindow)
self.tabWidget.setCurrentIndex(1) self.tabWidget.setCurrentIndex(1)
self.selectedDetailsStack.setCurrentIndex(0) self.selectedDetailsStack.setCurrentIndex(3)
QtCore.QMetaObject.connectSlotsByName(MainWindow) QtCore.QMetaObject.connectSlotsByName(MainWindow)
def retranslateUi(self, MainWindow): def retranslateUi(self, MainWindow):

View file

@ -157,7 +157,7 @@
</widget> </widget>
<widget class="QStackedWidget" name="selectedDetailsStack"> <widget class="QStackedWidget" name="selectedDetailsStack">
<property name="currentIndex"> <property name="currentIndex">
<number>0</number> <number>3</number>
</property> </property>
<widget class="QWidget" name="pageEmpty"> <widget class="QWidget" name="pageEmpty">
<layout class="QGridLayout" name="gridLayout_3"> <layout class="QGridLayout" name="gridLayout_3">
@ -854,7 +854,7 @@
<item> <item>
<widget class="QPushButton" name="resolvePairingBtn"> <widget class="QPushButton" name="resolvePairingBtn">
<property name="enabled"> <property name="enabled">
<bool>false</bool> <bool>true</bool>
</property> </property>
<property name="text"> <property name="text">
<string>Resolve pairing</string> <string>Resolve pairing</string>
@ -967,7 +967,7 @@
<x>0</x> <x>0</x>
<y>0</y> <y>0</y>
<width>849</width> <width>849</width>
<height>21</height> <height>22</height>
</rect> </rect>
</property> </property>
<widget class="QMenu" name="menuFile"> <widget class="QMenu" name="menuFile">