avoid rematch on choice fallback

This commit is contained in:
Maxime Réaux 2026-03-20 20:48:52 +01:00
parent b7a35f6712
commit d3101bc9f6
3 changed files with 65 additions and 9 deletions

View file

@ -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

View file

@ -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

View file

@ -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(