diff --git a/src/warchron/controller/presenter.py b/src/warchron/controller/presenter.py index d41a336..09362cc 100644 --- a/src/warchron/controller/presenter.py +++ b/src/warchron/controller/presenter.py @@ -30,7 +30,6 @@ 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/controller/round_controller.py b/src/warchron/controller/round_controller.py index fb11b7b..393a76b 100644 --- a/src/warchron/controller/round_controller.py +++ b/src/warchron/controller/round_controller.py @@ -86,7 +86,6 @@ class RoundController: rnd, part.id, ) - # TODO clarify status icons if alloc.priority != ChoiceStatus.NONE: priority_icon = QIcon( Icons.get_pixmap(IconName[alloc.priority.name]) diff --git a/src/warchron/model/history.py b/src/warchron/model/history.py new file mode 100644 index 0000000..d0a1751 --- /dev/null +++ b/src/warchron/model/history.py @@ -0,0 +1,20 @@ +from __future__ import annotations +from typing import Dict, Tuple + +from warchron.model.campaign import Campaign + + +class CampaignHistory: + + @staticmethod + def build_match_count(campaign: Campaign) -> Dict[Tuple[str, str], int]: + counts: Dict[Tuple[str, str], int] = {} + for rnd in campaign.rounds: + for battle in rnd.battles.values(): + p1 = battle.player_1_id + p2 = battle.player_2_id + if not p1 or not p2: + continue + key = (p1, p2) if p1 < p2 else (p2, p1) + counts[key] = counts.get(key, 0) + 1 + return counts diff --git a/src/warchron/model/pairing.py b/src/warchron/model/pairing.py index 3c8efae..50384a0 100644 --- a/src/warchron/model/pairing.py +++ b/src/warchron/model/pairing.py @@ -1,9 +1,8 @@ from __future__ import annotations -from typing import Dict, List, Tuple, TYPE_CHECKING -from collections import defaultdict +from typing import Dict, List, Tuple from dataclasses import dataclass from uuid import uuid4 -from copy import deepcopy + import random from warchron.constants import ContextType, ScoreKind, ChoiceStatus, AllocationType @@ -19,9 +18,7 @@ from warchron.model.scoring import ScoreComputer from warchron.model.tiebreaking import TieBreaker, TieContext, ResolveTiesCallback from warchron.model.war_event import TieResolved from warchron.model.scoring import ParticipantScore - -if TYPE_CHECKING: - from warchron.model.campaign import Campaign +from warchron.model.history import CampaignHistory ScoredBattle = Tuple[int, Battle] @@ -33,14 +30,6 @@ 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 @@ -271,58 +260,15 @@ class Pairing: context.participants, ) ordered: List[str] = [] - refined_groups = Pairing._refine_with_war_ranking( - war, - ranked_groups, - ) - for group in refined_groups: + for group in ranked_groups: shuffled_group = list(group) + # TODO improve tie break with history parsing random.shuffle(shuffled_group) ordered.extend( campaign.war_to_campaign_part_id(pid) for pid in shuffled_group ) return ordered[:places] - @staticmethod - def _refine_with_war_ranking( - war: War, - ranked_groups: List[List[str]], - ) -> List[List[str]]: - from warchron.model.checking import ResultChecker - - scores = ScoreComputer.compute_scores( - war, - ContextType.WAR, - war.id, - ) - - def value_getter(score: ParticipantScore) -> int: - return score.victory_points - - war_ranking = ResultChecker.get_effective_ranking( - war, - ContextType.WAR, - war.id, - ScoreKind.VP, - scores, - value_getter, - ) - rank_map: Dict[str, int] = {} - for rank, group, _ in war_ranking: - for pid in group: - rank_map[pid] = rank - refined: List[List[str]] = [] - for group in ranked_groups: - if len(group) <= 1: - refined.append(group) - continue - buckets: Dict[int, List[str]] = defaultdict(list) - for pid in group: - buckets[rank_map.get(pid, 10**9)].append(pid) - for rank in sorted(buckets.keys()): - refined.append(buckets[rank]) - return refined - @staticmethod def _assign_fallback( war: War, @@ -332,88 +278,37 @@ class Pairing: campaign = war.get_campaign_by_round(round.id) if campaign is None: raise DomainError("Campaign not found") - match_counts = Pairing.build_match_count(campaign) - 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) + match_counts = CampaignHistory.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) remaining.remove(pid) @staticmethod - 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( + def _fallback_score( pid: str, - occupants: List[str], + battle: Battle, 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: @@ -421,19 +316,6 @@ class Pairing: rematch_penalty += match_counts.get(key, 0) return rematch_penalty * rematch_weight + occupancy_penalty * occupancy_weight - @staticmethod - def build_match_count(campaign: Campaign) -> Dict[Tuple[str, str], int]: - counts: Dict[Tuple[str, str], int] = {} - for rnd in campaign.rounds: - for battle in rnd.battles.values(): - p1 = battle.player_1_id - p2 = battle.player_2_id - if not p1 or not p2: - continue - key = (p1, p2) if p1 < p2 else (p2, p1) - counts[key] = counts.get(key, 0) + 1 - return counts - @staticmethod def get_allocation_kind( war: War, diff --git a/src/warchron/model/round.py b/src/warchron/model/round.py index 4b1a630..ce4c73b 100644 --- a/src/warchron/model/round.py +++ b/src/warchron/model/round.py @@ -166,7 +166,6 @@ 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 c34077d..fc25fca 100644 --- a/src/warchron/view/view.py +++ b/src/warchron/view/view.py @@ -201,7 +201,6 @@ 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))