optimise global fallbcak score

This commit is contained in:
Maxime Réaux 2026-03-24 15:30:05 +01:00
parent 2505573250
commit c144845376
4 changed files with 85 additions and 23 deletions

View file

@ -30,6 +30,7 @@ class Presenter:
campaign: Campaign | None = None, campaign: Campaign | None = None,
round: Round | None = None, round: Round | None = None,
) -> TieDialogData: ) -> TieDialogData:
# TODO display Nth place
if ctx.context_type == ContextType.WAR: if ctx.context_type == ContextType.WAR:
if ctx.objective_id: if ctx.objective_id:
obj = war.objectives[ctx.objective_id] obj = war.objectives[ctx.objective_id]

View file

@ -3,7 +3,7 @@ from typing import Dict, List, Tuple, TYPE_CHECKING
from collections import defaultdict from collections import defaultdict
from dataclasses import dataclass from dataclasses import dataclass
from uuid import uuid4 from uuid import uuid4
from copy import deepcopy
import random import random
from warchron.constants import ContextType, ScoreKind, ChoiceStatus, AllocationType from warchron.constants import ContextType, ScoreKind, ChoiceStatus, AllocationType
@ -33,6 +33,14 @@ class AllocationResult:
fallback: bool 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: class Pairing:
@staticmethod @staticmethod
@ -325,36 +333,87 @@ class Pairing:
if campaign is None: if campaign is None:
raise DomainError("Campaign not found") raise DomainError("Campaign not found")
match_counts = Pairing.build_match_count(campaign) match_counts = Pairing.build_match_count(campaign)
random.shuffle(remaining) available_battles = round.get_battles_with_places()
for pid in list(remaining): if not available_battles:
available = round.get_battles_with_places() raise DomainError("No available battle remaining")
if not available: occupancy = {
raise DomainError("No available battle remaining") b.sector_id: [p for p in (b.player_1_id, b.player_2_id) if p is not None]
scored: List[ScoredBattle] = [ for b in round.battles.values()
( }
Pairing._fallback_score(pid, battle, match_counts), shuffled_remaining = list(remaining)
battle, random.shuffle(shuffled_remaining)
) initial = _FallbackState(
for battle in available assignments=[],
] remaining=shuffled_remaining,
scored.sort(key=lambda x: x[0]) occupancy=occupancy,
best_score = scored[0][0] score=0,
best_battles = [b for s, b in scored if s == best_score] )
chosen = sorted(best_battles, key=lambda b: b.sector_id)[0] best = Pairing._search_best_fallback(
chosen.assign_participant(pid) 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) remaining.remove(pid)
@staticmethod @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, pid: str,
battle: Battle, occupants: List[str],
match_counts: Dict[Tuple[str, str], int], match_counts: Dict[Tuple[str, str], int],
) -> int: ) -> int:
rematch_weight = 1 rematch_weight = 1
occupancy_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) occupancy_penalty = len(occupants)
rematch_penalty = 0 rematch_penalty = 0
for opp in occupants: for opp in occupants:

View file

@ -166,6 +166,7 @@ class Round:
return any(b.is_finished() for b in self.battles.values()) return any(b.is_finished() for b in self.battles.values())
def all_battles_finished(self) -> bool: def all_battles_finished(self) -> bool:
# TODO exception for participant alone
return all( return all(
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()
) )

View file

@ -201,6 +201,7 @@ class View(QtWidgets.QMainWindow, Ui_MainWindow):
self.on_delete_item(ItemType.PLAYER, player_id) self.on_delete_item(ItemType.PLAYER, player_id)
def display_players(self, players: List[ParticipantOption]) -> None: def display_players(self, players: List[ParticipantOption]) -> None:
# TODO display stats (war, campaign battles...)
table = self.playersTable table = self.playersTable
table.setSortingEnabled(False) table.setSortingEnabled(False)
table.setRowCount(len(players)) table.setRowCount(len(players))