From d3101bc9f6e251c9758bac6e8588b5d8866731ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20R=C3=A9aux?= Date: Fri, 20 Mar 2026 20:48:52 +0100 Subject: [PATCH] avoid rematch on choice fallback --- README.md | 2 +- src/warchron/model/history.py | 20 ++++++++++++++ src/warchron/model/pairing.py | 52 +++++++++++++++++++++++++++++------ 3 files changed, 65 insertions(+), 9 deletions(-) create mode 100644 src/warchron/model/history.py diff --git a/README.md b/README.md index 6b8c7e7..51aead1 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ A campaign event presents customisable sectors to fight on during battle rounds. A round includes battles to combine all participants according to their choice. Rounds are successive and are used for participants pairing in different priority modes. Winning battle grants victory points, narrative points (optional) and influence token (optional). Round results determine campaign score, which determines the war score, in different counting modes. -Victory points determine the winner, narrative points grant scenario award(s) and influence tokens decide tie-breaks. +Victory points determine the winner, narrative points determine the scenario(s) achiever(s) and influence tokens decide tie-breaks. ## Installation 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 a8c0613..5b9576b 100644 --- a/src/warchron/model/pairing.py +++ b/src/warchron/model/pairing.py @@ -1,5 +1,5 @@ from __future__ import annotations -from typing import Dict, List +from typing import Dict, List, Tuple from dataclasses import dataclass from uuid import uuid4 @@ -18,6 +18,9 @@ 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 +from warchron.model.history import CampaignHistory + +ScoredBattle = Tuple[int, Battle] @dataclass(frozen=True, slots=True) @@ -115,7 +118,7 @@ class Pairing: use_priority=False, score_value=score_value, ) - Pairing._assign_fallback(round, remaining) + Pairing._assign_fallback(war, round, remaining) @staticmethod def _run_phase( @@ -268,19 +271,52 @@ class Pairing: @staticmethod def _assign_fallback( + war: War, round: Round, remaining: List[str], ) -> None: - # TODO avoid rematch + campaign = war.get_campaign_by_round(round.id) + if campaign is None: + raise DomainError("Campaign not found") + 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") - if len(available) == 1: - available[0].assign_participant(pid) - remaining.remove(pid) - continue - raise DomainError(f"Ambiguous fallback for participant {pid}") + scored: List[ScoredBattle] = [ + ( + Pairing._fallback_score(pid, battle, match_counts), + battle, + ) + for battle in available + ] + print(f"scored for pid {pid}: {scored}") + 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 _fallback_score( + pid: 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: + key = (pid, opp) if pid < opp else (opp, pid) + print(f"key for occupant {opp}: {key}") + rematch_penalty += match_counts.get(key, 0) + return rematch_penalty * rematch_weight + occupancy_penalty * occupancy_weight @staticmethod def get_allocation_kind(