From c144845376358a49c83c16cedac9e46a8ac0b5a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20R=C3=A9aux?= Date: Tue, 24 Mar 2026 15:30:05 +0100 Subject: [PATCH] optimise global fallbcak score --- src/warchron/controller/presenter.py | 1 + src/warchron/model/pairing.py | 105 +++++++++++++++++++++------ src/warchron/model/round.py | 1 + src/warchron/view/view.py | 1 + 4 files changed, 85 insertions(+), 23 deletions(-) diff --git a/src/warchron/controller/presenter.py b/src/warchron/controller/presenter.py index 09362cc..d41a336 100644 --- a/src/warchron/controller/presenter.py +++ b/src/warchron/controller/presenter.py @@ -30,6 +30,7 @@ class Presenter: campaign: Campaign | None = None, round: Round | None = None, ) -> TieDialogData: + # TODO display Nth place if ctx.context_type == ContextType.WAR: if ctx.objective_id: obj = war.objectives[ctx.objective_id] diff --git a/src/warchron/model/pairing.py b/src/warchron/model/pairing.py index 5ec90b0..3c8efae 100644 --- a/src/warchron/model/pairing.py +++ b/src/warchron/model/pairing.py @@ -3,7 +3,7 @@ from typing import Dict, List, Tuple, TYPE_CHECKING from collections import defaultdict from dataclasses import dataclass from uuid import uuid4 - +from copy import deepcopy import random from warchron.constants import ContextType, ScoreKind, ChoiceStatus, AllocationType @@ -33,6 +33,14 @@ class AllocationResult: fallback: bool +@dataclass +class _FallbackState: + assignments: List[Tuple[str, str]] # (pid, battle_id) + remaining: List[str] + occupancy: Dict[str, List[str]] + score: int + + class Pairing: @staticmethod @@ -325,36 +333,87 @@ class Pairing: if campaign is None: raise DomainError("Campaign not found") match_counts = Pairing.build_match_count(campaign) - random.shuffle(remaining) - for pid in list(remaining): - available = round.get_battles_with_places() - if not available: - raise DomainError("No available battle remaining") - scored: List[ScoredBattle] = [ - ( - Pairing._fallback_score(pid, battle, match_counts), - battle, - ) - for battle in available - ] - scored.sort(key=lambda x: x[0]) - best_score = scored[0][0] - best_battles = [b for s, b in scored if s == best_score] - chosen = sorted(best_battles, key=lambda b: b.sector_id)[0] - chosen.assign_participant(pid) + available_battles = round.get_battles_with_places() + if not available_battles: + raise DomainError("No available battle remaining") + occupancy = { + b.sector_id: [p for p in (b.player_1_id, b.player_2_id) if p is not None] + for b in round.battles.values() + } + shuffled_remaining = list(remaining) + random.shuffle(shuffled_remaining) + initial = _FallbackState( + assignments=[], + remaining=shuffled_remaining, + occupancy=occupancy, + score=0, + ) + best = Pairing._search_best_fallback( + initial, + match_counts, + beam_width=10, + ) + battle_map = {b.sector_id: b for b in round.battles.values()} + for pid, bid in best.assignments: + battle_map[bid].assign_participant(pid) remaining.remove(pid) @staticmethod - def _fallback_score( + def _search_best_fallback( + initial: _FallbackState, + match_counts: Dict[Tuple[str, str], int], + beam_width: int, + ) -> _FallbackState: + states = [initial] + while True: + new_states: List[_FallbackState] = [] + progress = False + for state in states: + if not state.remaining: + new_states.append(state) + continue + progress = True + pid = state.remaining[0] + next_remaining = state.remaining[1:] + battles = Pairing._available_virtual_battles(state) + random.shuffle(battles) + for sector_id in battles: + occupants = state.occupancy[sector_id] + if len(occupants) >= 2: + continue + score = Pairing._fallback_score_virtual( + pid, + occupants, + match_counts, + ) + new_occupancy = deepcopy(state.occupancy) + new_occupancy[sector_id] = occupants + [pid] + new_states.append( + _FallbackState( + assignments=state.assignments + [(pid, sector_id)], + remaining=next_remaining, + occupancy=new_occupancy, + score=state.score + score, + ) + ) + if not progress: + break + new_states.sort(key=lambda s: (s.score, random.random())) + states = new_states[:beam_width] + return min(states, key=lambda s: s.score) + + @staticmethod + def _available_virtual_battles(state: _FallbackState) -> List[str]: + return [sector_id for sector_id, occ in state.occupancy.items() if len(occ) < 2] + + @staticmethod + def _fallback_score_virtual( pid: str, - battle: Battle, + occupants: List[str], match_counts: Dict[Tuple[str, str], int], ) -> int: rematch_weight = 1 occupancy_weight = 1 - occupants = [ - p for p in (battle.player_1_id, battle.player_2_id) if p is not None - ] occupancy_penalty = len(occupants) rematch_penalty = 0 for opp in occupants: diff --git a/src/warchron/model/round.py b/src/warchron/model/round.py index ce4c73b..4b1a630 100644 --- a/src/warchron/model/round.py +++ b/src/warchron/model/round.py @@ -166,6 +166,7 @@ class Round: return any(b.is_finished() for b in self.battles.values()) def all_battles_finished(self) -> bool: + # TODO exception for participant alone return all( b.winner_id is not None or b.is_draw() for b in self.battles.values() ) diff --git a/src/warchron/view/view.py b/src/warchron/view/view.py index fc25fca..c34077d 100644 --- a/src/warchron/view/view.py +++ b/src/warchron/view/view.py @@ -201,6 +201,7 @@ class View(QtWidgets.QMainWindow, Ui_MainWindow): self.on_delete_item(ItemType.PLAYER, player_id) def display_players(self, players: List[ParticipantOption]) -> None: + # TODO display stats (war, campaign battles...) table = self.playersTable table.setSortingEnabled(False) table.setRowCount(len(players))