choice tie-break and random fallback + exception cleanup + war<->camp pid
This commit is contained in:
parent
241d7f10f5
commit
241e76c937
15 changed files with 241 additions and 157 deletions
|
|
@ -111,7 +111,15 @@ class AppController:
|
||||||
path = self.view.ask_open_file()
|
path = self.view.ask_open_file()
|
||||||
if not path:
|
if not path:
|
||||||
return
|
return
|
||||||
self.model.load(path)
|
try:
|
||||||
|
self.model.load(path)
|
||||||
|
except RuntimeError as e:
|
||||||
|
QMessageBox.warning(
|
||||||
|
self.view,
|
||||||
|
"Add forbidden",
|
||||||
|
str(e),
|
||||||
|
)
|
||||||
|
return
|
||||||
self.current_file = path
|
self.current_file = path
|
||||||
self.is_dirty = False
|
self.is_dirty = False
|
||||||
self.navigation.refresh_players_view()
|
self.navigation.refresh_players_view()
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ from warchron.controller.dtos import (
|
||||||
RoundDTO,
|
RoundDTO,
|
||||||
CampaignParticipantScoreDTO,
|
CampaignParticipantScoreDTO,
|
||||||
)
|
)
|
||||||
from warchron.model.exception import ForbiddenOperation, DomainError
|
from warchron.model.exception import AbortedOperation, DomainError
|
||||||
from warchron.model.war import War
|
from warchron.model.war import War
|
||||||
from warchron.model.campaign import Campaign
|
from warchron.model.campaign import Campaign
|
||||||
from warchron.model.campaign_participant import CampaignParticipant
|
from warchron.model.campaign_participant import CampaignParticipant
|
||||||
|
|
@ -196,7 +196,7 @@ class CampaignController:
|
||||||
)
|
)
|
||||||
if not dialog.exec():
|
if not dialog.exec():
|
||||||
TieResolver.cancel_tie_break(war, ctx)
|
TieResolver.cancel_tie_break(war, ctx)
|
||||||
raise ForbiddenOperation("Tie resolution cancelled")
|
raise AbortedOperation("Tie resolution cancelled")
|
||||||
bids_map[ctx.key()] = dialog.get_bids()
|
bids_map[ctx.key()] = dialog.get_bids()
|
||||||
return bids_map
|
return bids_map
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -104,12 +104,8 @@ class RoundPairingWorkflow:
|
||||||
|
|
||||||
def start(self, war: War, round: Round) -> None:
|
def start(self, war: War, round: Round) -> None:
|
||||||
Pairing.check_round_pairable(round)
|
Pairing.check_round_pairable(round)
|
||||||
ties = TieResolver.find_choice_ties(war, round.id)
|
Pairing.assign_battles_to_participants(
|
||||||
while ties:
|
war,
|
||||||
bids_map = self.app.rounds.resolve_ties(war, ties)
|
round,
|
||||||
for tie in ties:
|
resolve_ties_callback=self.app.rounds.resolve_ties,
|
||||||
bids = bids_map[tie.key()]
|
)
|
||||||
TieResolver.apply_bids(war, tie, bids)
|
|
||||||
TieResolver.resolve_tie_state(war, tie, bids)
|
|
||||||
ties = TieResolver.find_choice_ties(war, round.id)
|
|
||||||
Pairing.assign_battles_to_participants(war, round)
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ from PyQt6.QtGui import QIcon
|
||||||
|
|
||||||
from warchron.constants import ItemType, RefreshScope, Icons, IconName, ContextType
|
from warchron.constants import ItemType, RefreshScope, Icons, IconName, ContextType
|
||||||
from warchron.model.exception import (
|
from warchron.model.exception import (
|
||||||
ForbiddenOperation,
|
AbortedOperation,
|
||||||
DomainError,
|
DomainError,
|
||||||
RequiresConfirmation,
|
RequiresConfirmation,
|
||||||
)
|
)
|
||||||
|
|
@ -119,9 +119,7 @@ class RoundController:
|
||||||
)
|
)
|
||||||
p1_war = None
|
p1_war = None
|
||||||
if battle.player_1_id is not None:
|
if battle.player_1_id is not None:
|
||||||
p1_war = camp.participants[
|
p1_war = camp.campaign_to_war_part_id(battle.player_1_id)
|
||||||
battle.player_1_id
|
|
||||||
].war_participant_id
|
|
||||||
pixmap = Icons.get_pixmap(IconName.TIEBREAK_TOKEN)
|
pixmap = Icons.get_pixmap(IconName.TIEBREAK_TOKEN)
|
||||||
if effective_winner == p1_war:
|
if effective_winner == p1_war:
|
||||||
p1_icon = QIcon(pixmap)
|
p1_icon = QIcon(pixmap)
|
||||||
|
|
@ -192,10 +190,19 @@ class RoundController:
|
||||||
workflow = RoundPairingWorkflow(self.app)
|
workflow = RoundPairingWorkflow(self.app)
|
||||||
try:
|
try:
|
||||||
workflow.start(war, rnd)
|
workflow.start(war, rnd)
|
||||||
|
except AbortedOperation as e:
|
||||||
|
QMessageBox.warning(
|
||||||
|
self.app.view,
|
||||||
|
"Canceled pairing",
|
||||||
|
str(e),
|
||||||
|
)
|
||||||
|
for bat in rnd.battles.values():
|
||||||
|
bat.cleanup_battle_players()
|
||||||
|
return
|
||||||
except DomainError as e:
|
except DomainError as e:
|
||||||
QMessageBox.warning(
|
QMessageBox.warning(
|
||||||
self.app.view,
|
self.app.view,
|
||||||
"Closure forbidden",
|
"Pairing impossible",
|
||||||
str(e),
|
str(e),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
@ -238,7 +245,7 @@ class RoundController:
|
||||||
)
|
)
|
||||||
if not dialog.exec():
|
if not dialog.exec():
|
||||||
TieResolver.cancel_tie_break(war, ctx)
|
TieResolver.cancel_tie_break(war, ctx)
|
||||||
raise ForbiddenOperation("Tie resolution cancelled")
|
raise AbortedOperation("Tie resolution cancelled")
|
||||||
bids_map[ctx.key()] = dialog.get_bids()
|
bids_map[ctx.key()] = dialog.get_bids()
|
||||||
return bids_map
|
return bids_map
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ from warchron.constants import (
|
||||||
)
|
)
|
||||||
from warchron.model.exception import (
|
from warchron.model.exception import (
|
||||||
DomainError,
|
DomainError,
|
||||||
ForbiddenOperation,
|
AbortedOperation,
|
||||||
RequiresConfirmation,
|
RequiresConfirmation,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -190,7 +190,7 @@ class WarController:
|
||||||
)
|
)
|
||||||
if not dialog.exec():
|
if not dialog.exec():
|
||||||
TieResolver.cancel_tie_break(war, ctx)
|
TieResolver.cancel_tie_break(war, ctx)
|
||||||
raise ForbiddenOperation("Tie resolution cancelled")
|
raise AbortedOperation("Tie resolution cancelled")
|
||||||
bids_map[ctx.key()] = dialog.get_bids()
|
bids_map[ctx.key()] = dialog.get_bids()
|
||||||
return bids_map
|
return bids_map
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
from warchron.model.exception import DomainError
|
||||||
from warchron.model.json_helper import JsonHelper
|
from warchron.model.json_helper import JsonHelper
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -70,7 +71,7 @@ class Battle:
|
||||||
if self.player_2_id is None:
|
if self.player_2_id is None:
|
||||||
self.player_2_id = participant_id
|
self.player_2_id = participant_id
|
||||||
return
|
return
|
||||||
raise RuntimeError("Battle has no available places")
|
raise DomainError("Battle has no available places")
|
||||||
|
|
||||||
def cleanup_battle_players(self) -> None:
|
def cleanup_battle_players(self) -> None:
|
||||||
self.player_1_id = None
|
self.player_1_id = None
|
||||||
|
|
|
||||||
|
|
@ -91,14 +91,21 @@ class Campaign:
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise KeyError(f"Participant {participant_id} not in campaign {self.id}")
|
raise KeyError(f"Participant {participant_id} not in campaign {self.id}")
|
||||||
|
|
||||||
def get_campaign_participant_by_war_participant_id(
|
def campaign_to_war_part_id(
|
||||||
self,
|
self,
|
||||||
war_participant_id: str,
|
campaign_pid: str,
|
||||||
) -> CampaignParticipant | None:
|
) -> str:
|
||||||
|
cp = self.get_campaign_participant(campaign_pid)
|
||||||
|
return cp.war_participant_id
|
||||||
|
|
||||||
|
def war_to_campaign_part_id(
|
||||||
|
self,
|
||||||
|
war_pid: str,
|
||||||
|
) -> str:
|
||||||
for cp in self.participants.values():
|
for cp in self.participants.values():
|
||||||
if cp.war_participant_id == war_participant_id:
|
if cp.war_participant_id == war_pid:
|
||||||
return cp
|
return cp.id
|
||||||
return None
|
raise KeyError(f"War participant {war_pid} not within campaign participants")
|
||||||
|
|
||||||
def get_all_campaign_participants(self) -> List[CampaignParticipant]:
|
def get_all_campaign_participants(self) -> List[CampaignParticipant]:
|
||||||
return list(self.participants.values())
|
return list(self.participants.values())
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ class ClosureService:
|
||||||
return
|
return
|
||||||
base_winner = None
|
base_winner = None
|
||||||
if battle.winner_id is not None:
|
if battle.winner_id is not None:
|
||||||
base_winner = campaign.participants[battle.winner_id].war_participant_id
|
base_winner = campaign.campaign_to_war_part_id(battle.winner_id)
|
||||||
effective_winner = ResultChecker.get_effective_winner_id(
|
effective_winner = ResultChecker.get_effective_winner_id(
|
||||||
war,
|
war,
|
||||||
ContextType.BATTLE,
|
ContextType.BATTLE,
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,12 @@ class ForbiddenOperation(DomainError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AbortedOperation(DomainError):
|
||||||
|
"""Generic 'you canceled this' rule."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class DomainDecision(Exception):
|
class DomainDecision(Exception):
|
||||||
"""Base class for domain actions requiring user decision."""
|
"""Base class for domain actions requiring user decision."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from typing import Dict, List
|
from typing import Dict, List, Callable, Tuple
|
||||||
import random
|
import random
|
||||||
|
|
||||||
from warchron.constants import ContextType
|
from warchron.constants import ContextType, ScoreKind
|
||||||
from warchron.model.exception import (
|
from warchron.model.exception import (
|
||||||
DomainError,
|
DomainError,
|
||||||
ForbiddenOperation,
|
ForbiddenOperation,
|
||||||
|
|
@ -12,6 +12,14 @@ from warchron.model.war import War
|
||||||
from warchron.model.round import Round
|
from warchron.model.round import Round
|
||||||
from warchron.model.battle import Battle
|
from warchron.model.battle import Battle
|
||||||
from warchron.model.score_service import ScoreService
|
from warchron.model.score_service import ScoreService
|
||||||
|
from warchron.model.tie_manager import TieResolver, TieContext
|
||||||
|
from warchron.model.war_event import TieResolved
|
||||||
|
from warchron.model.score_service import ParticipantScore
|
||||||
|
|
||||||
|
ResolveTiesCallback = Callable[
|
||||||
|
["War", List["TieContext"]],
|
||||||
|
Dict[Tuple[str, str, int | None], Dict[str, bool]],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class Pairing:
|
class Pairing:
|
||||||
|
|
@ -30,13 +38,18 @@ class Pairing:
|
||||||
)
|
)
|
||||||
for pid, choice in round.choices.items():
|
for pid, choice in round.choices.items():
|
||||||
if choice is not None and not choice.priority_sector_id:
|
if choice is not None and not choice.priority_sector_id:
|
||||||
raise DomainError(f"Missing priority choice for participant {pid}")
|
raise ForbiddenOperation(
|
||||||
|
f"Missing priority choice for participant {pid}"
|
||||||
|
)
|
||||||
if choice is not None and not choice.secondary_sector_id:
|
if choice is not None and not choice.secondary_sector_id:
|
||||||
raise DomainError(f"Missing secondary choice for participant {pid}")
|
raise ForbiddenOperation(
|
||||||
|
f"Missing secondary choice for participant {pid}"
|
||||||
|
)
|
||||||
|
|
||||||
def cleanup() -> None:
|
def cleanup() -> None:
|
||||||
for bat in round.battles.values():
|
for bat in round.battles.values():
|
||||||
bat.cleanup_battle_players()
|
bat.cleanup_battle_players()
|
||||||
|
# FIXME cancel TieResolved + TokenSpent
|
||||||
|
|
||||||
if any(
|
if any(
|
||||||
bat.player_1_id is not None or bat.player_2_id is not None
|
bat.player_1_id is not None or bat.player_2_id is not None
|
||||||
|
|
@ -53,6 +66,7 @@ class Pairing:
|
||||||
def assign_battles_to_participants(
|
def assign_battles_to_participants(
|
||||||
war: War,
|
war: War,
|
||||||
round: Round,
|
round: Round,
|
||||||
|
resolve_ties_callback: ResolveTiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
campaign = war.get_campaign_by_round(round.id)
|
campaign = war.get_campaign_by_round(round.id)
|
||||||
if campaign is None:
|
if campaign is None:
|
||||||
|
|
@ -62,57 +76,162 @@ class Pairing:
|
||||||
ContextType.CAMPAIGN,
|
ContextType.CAMPAIGN,
|
||||||
campaign.id,
|
campaign.id,
|
||||||
)
|
)
|
||||||
score_groups = ScoreService.group_participants_by_score(
|
|
||||||
scores, lambda score: score.victory_points
|
def value_getter(score: ParticipantScore) -> int:
|
||||||
)
|
return score.victory_points
|
||||||
|
|
||||||
|
score_groups = ScoreService.group_participants_by_score(scores, value_getter)
|
||||||
sector_to_battle: Dict[str, Battle] = {
|
sector_to_battle: Dict[str, Battle] = {
|
||||||
b.sector_id: b for b in round.battles.values()
|
b.sector_id: b for b in round.battles.values()
|
||||||
}
|
}
|
||||||
for group in score_groups:
|
for group in score_groups:
|
||||||
# persistent equality → random order
|
score_value = value_getter(scores[group[0]])
|
||||||
ordered_group = list(group)
|
remaining: List[str] = [
|
||||||
random.shuffle(ordered_group)
|
campaign.war_to_campaign_part_id(pid) for pid in group
|
||||||
for participant_id in ordered_group:
|
]
|
||||||
camp_part = campaign.get_campaign_participant_by_war_participant_id(
|
Pairing._run_phase(
|
||||||
participant_id
|
war,
|
||||||
)
|
round,
|
||||||
if camp_part:
|
remaining,
|
||||||
Pairing._assign_single_participant(
|
sector_to_battle,
|
||||||
round,
|
resolve_ties_callback,
|
||||||
camp_part.id,
|
use_priority=True,
|
||||||
sector_to_battle,
|
score_value=score_value,
|
||||||
)
|
)
|
||||||
|
Pairing._run_phase(
|
||||||
|
war,
|
||||||
|
round,
|
||||||
|
remaining,
|
||||||
|
sector_to_battle,
|
||||||
|
resolve_ties_callback,
|
||||||
|
use_priority=False,
|
||||||
|
score_value=score_value,
|
||||||
|
)
|
||||||
|
Pairing._assign_fallback(round, remaining)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _assign_single_participant(
|
def _run_phase(
|
||||||
|
war: War,
|
||||||
round: Round,
|
round: Round,
|
||||||
participant_id: str,
|
remaining: List[str],
|
||||||
sector_to_battle: Dict[str, Battle],
|
sector_to_battle: Dict[str, Battle],
|
||||||
|
resolve_ties_callback: ResolveTiesCallback,
|
||||||
|
*,
|
||||||
|
use_priority: bool,
|
||||||
|
score_value: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
choice = round.choices.get(participant_id)
|
demand = Pairing._build_sector_demand(
|
||||||
preferred_sectors: List[str] = []
|
round,
|
||||||
if choice:
|
remaining,
|
||||||
if choice.priority_sector_id:
|
use_priority,
|
||||||
preferred_sectors.append(choice.priority_sector_id)
|
)
|
||||||
if choice.secondary_sector_id:
|
for sector_id, participants in demand.items():
|
||||||
preferred_sectors.append(choice.secondary_sector_id)
|
battle = sector_to_battle.get(sector_id)
|
||||||
# --- try preferred sectors ---
|
|
||||||
for sect_id in preferred_sectors:
|
|
||||||
battle = sector_to_battle.get(sect_id)
|
|
||||||
if not battle:
|
if not battle:
|
||||||
continue
|
continue
|
||||||
if battle.get_available_places():
|
places = len(battle.get_available_places())
|
||||||
battle.assign_participant(participant_id)
|
if places <= 0:
|
||||||
return
|
continue
|
||||||
# --- fallback rules ---
|
winners = Pairing._resolve_sector_allocation(
|
||||||
available_battles = round.get_battles_with_places()
|
war,
|
||||||
if not available_battles:
|
round,
|
||||||
raise RuntimeError("No available battle remaining")
|
sector_id,
|
||||||
if len(available_battles) == 1:
|
participants,
|
||||||
available_battles[0].assign_participant(participant_id)
|
places,
|
||||||
return
|
resolve_ties_callback,
|
||||||
# multiple remaining battles → warning
|
score_value,
|
||||||
raise RuntimeError(
|
)
|
||||||
f"Ambiguous fallback for participant {participant_id}: "
|
for pid in winners:
|
||||||
"multiple battles still available"
|
battle.assign_participant(pid)
|
||||||
|
remaining.remove(pid)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_sector_demand(
|
||||||
|
round: Round,
|
||||||
|
participants: List[str],
|
||||||
|
use_priority: bool,
|
||||||
|
) -> Dict[str, List[str]]:
|
||||||
|
demand: Dict[str, List[str]] = {}
|
||||||
|
for pid in participants:
|
||||||
|
choice = round.choices.get(pid)
|
||||||
|
if not choice:
|
||||||
|
continue
|
||||||
|
sector_id = (
|
||||||
|
choice.priority_sector_id
|
||||||
|
if use_priority
|
||||||
|
else choice.secondary_sector_id
|
||||||
|
)
|
||||||
|
if not sector_id:
|
||||||
|
continue
|
||||||
|
demand.setdefault(sector_id, []).append(pid)
|
||||||
|
return demand
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_sector_allocation(
|
||||||
|
war: War,
|
||||||
|
round: Round,
|
||||||
|
sector_id: str,
|
||||||
|
participants: List[str],
|
||||||
|
places: int,
|
||||||
|
resolve_ties_callback: ResolveTiesCallback,
|
||||||
|
score_value: int,
|
||||||
|
) -> List[str]:
|
||||||
|
if len(participants) <= places:
|
||||||
|
return participants
|
||||||
|
campaign = war.get_campaign_by_round(round.id)
|
||||||
|
if campaign is None:
|
||||||
|
raise DomainError("Campaign not found for round {round.id}")
|
||||||
|
context = TieContext(
|
||||||
|
context_type=ContextType.CHOICE,
|
||||||
|
context_id=round.id,
|
||||||
|
participants=[
|
||||||
|
campaign.campaign_to_war_part_id(pid) for pid in participants
|
||||||
|
],
|
||||||
|
score_kind=ScoreKind.VP,
|
||||||
|
sector_id=sector_id,
|
||||||
)
|
)
|
||||||
|
# ---- resolve tie loop ----
|
||||||
|
while not TieResolver.is_tie_resolved(war, context):
|
||||||
|
if not TieResolver.can_tie_be_resolved(war, context, context.participants):
|
||||||
|
war.events.append(
|
||||||
|
TieResolved(
|
||||||
|
None,
|
||||||
|
context.context_type,
|
||||||
|
context.context_id,
|
||||||
|
score_value=score_value,
|
||||||
|
sector_id=sector_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
break
|
||||||
|
bids_map = resolve_ties_callback(war, [context])
|
||||||
|
bids = bids_map[context.key()]
|
||||||
|
TieResolver.apply_bids(war, context, bids)
|
||||||
|
TieResolver.resolve_tie_state(war, context, bids)
|
||||||
|
ranked_groups = TieResolver.rank_by_tokens(
|
||||||
|
war,
|
||||||
|
context,
|
||||||
|
context.participants,
|
||||||
|
)
|
||||||
|
ordered: List[str] = []
|
||||||
|
for group in ranked_groups:
|
||||||
|
shuffled_group = list(group)
|
||||||
|
random.shuffle(shuffled_group)
|
||||||
|
ordered.extend(
|
||||||
|
campaign.war_to_campaign_part_id(pid) for pid in shuffled_group
|
||||||
|
)
|
||||||
|
return ordered[:places]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _assign_fallback(
|
||||||
|
round: Round,
|
||||||
|
remaining: List[str],
|
||||||
|
) -> None:
|
||||||
|
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}")
|
||||||
|
|
|
||||||
|
|
@ -56,9 +56,7 @@ class ScoreService:
|
||||||
if battle.winner_id is not None:
|
if battle.winner_id is not None:
|
||||||
campaign = war.get_campaign_by_campaign_participant(battle.winner_id)
|
campaign = war.get_campaign_by_campaign_participant(battle.winner_id)
|
||||||
if campaign is not None:
|
if campaign is not None:
|
||||||
base_winner = campaign.participants[
|
base_winner = campaign.campaign_to_war_part_id(battle.winner_id)
|
||||||
battle.winner_id
|
|
||||||
].war_participant_id
|
|
||||||
winner = ResultChecker.get_effective_winner_id(
|
winner = ResultChecker.get_effective_winner_id(
|
||||||
war, ContextType.BATTLE, battle.sector_id, base_winner
|
war, ContextType.BATTLE, battle.sector_id, base_winner
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ from dataclasses import dataclass, field
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
||||||
from warchron.constants import ContextType, ScoreKind
|
from warchron.constants import ContextType, ScoreKind
|
||||||
from warchron.model.exception import ForbiddenOperation
|
from warchron.model.exception import ForbiddenOperation, DomainError
|
||||||
from warchron.model.war import War
|
from warchron.model.war import War
|
||||||
from warchron.model.war_event import InfluenceSpent, TieResolved
|
from warchron.model.war_event import InfluenceSpent, TieResolved
|
||||||
from warchron.model.score_service import ScoreService, ParticipantScore
|
from warchron.model.score_service import ScoreService, ParticipantScore
|
||||||
|
|
@ -17,6 +17,7 @@ class TieContext:
|
||||||
score_value: int | None = None
|
score_value: int | None = None
|
||||||
score_kind: ScoreKind | None = None
|
score_kind: ScoreKind | None = None
|
||||||
objective_id: str | None = None
|
objective_id: str | None = None
|
||||||
|
sector_id: str | None = None
|
||||||
|
|
||||||
def key(self) -> tuple[str, str, int | None]:
|
def key(self) -> tuple[str, str, int | None]:
|
||||||
return (self.context_type, self.context_id, self.score_value)
|
return (self.context_type, self.context_id, self.score_value)
|
||||||
|
|
@ -24,71 +25,6 @@ class TieContext:
|
||||||
|
|
||||||
class TieResolver:
|
class TieResolver:
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def find_choice_ties(
|
|
||||||
war: War,
|
|
||||||
round_id: str,
|
|
||||||
) -> List[TieContext]:
|
|
||||||
round = war.get_round(round_id)
|
|
||||||
campaign = war.get_campaign_by_round(round_id)
|
|
||||||
if campaign is None:
|
|
||||||
raise RuntimeError("Round without campaign")
|
|
||||||
ties: List[TieContext] = []
|
|
||||||
scores = ScoreService.compute_scores(
|
|
||||||
war,
|
|
||||||
ContextType.CAMPAIGN,
|
|
||||||
campaign.id,
|
|
||||||
)
|
|
||||||
score_groups = ScoreService.group_participants_by_score(
|
|
||||||
scores, lambda score: score.victory_points
|
|
||||||
)
|
|
||||||
sector_to_battle = {b.sector_id: b for b in round.battles.values()}
|
|
||||||
for group in score_groups:
|
|
||||||
if len(group) <= 1:
|
|
||||||
continue
|
|
||||||
demand: Dict[str, List[str]] = {}
|
|
||||||
for pid in group:
|
|
||||||
choice = round.choices.get(pid)
|
|
||||||
if not choice:
|
|
||||||
continue
|
|
||||||
for sec_id in (
|
|
||||||
choice.priority_sector_id,
|
|
||||||
choice.secondary_sector_id,
|
|
||||||
):
|
|
||||||
if sec_id:
|
|
||||||
demand.setdefault(sec_id, []).append(pid)
|
|
||||||
for sector_id, demanders in demand.items():
|
|
||||||
battle = sector_to_battle.get(sector_id)
|
|
||||||
if battle is None:
|
|
||||||
continue
|
|
||||||
places = len(battle.get_available_places())
|
|
||||||
if len(demanders) <= places:
|
|
||||||
continue
|
|
||||||
context = TieContext(
|
|
||||||
ContextType.CHOICE,
|
|
||||||
round_id,
|
|
||||||
demanders,
|
|
||||||
score_value=None,
|
|
||||||
score_kind=ScoreKind.VP,
|
|
||||||
)
|
|
||||||
if TieResolver.is_tie_resolved(war, context):
|
|
||||||
continue
|
|
||||||
if not TieResolver.can_tie_be_resolved(
|
|
||||||
war,
|
|
||||||
context,
|
|
||||||
demanders,
|
|
||||||
):
|
|
||||||
war.events.append(
|
|
||||||
TieResolved(
|
|
||||||
None,
|
|
||||||
ContextType.CHOICE,
|
|
||||||
round_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
ties.append(context)
|
|
||||||
return ties
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def find_battle_ties(war: War, round_id: str) -> List[TieContext]:
|
def find_battle_ties(war: War, round_id: str) -> List[TieContext]:
|
||||||
round = war.get_round(round_id)
|
round = war.get_round(round_id)
|
||||||
|
|
@ -104,12 +40,12 @@ class TieResolver:
|
||||||
if TieResolver.is_tie_resolved(war, context):
|
if TieResolver.is_tie_resolved(war, context):
|
||||||
continue
|
continue
|
||||||
if campaign is None:
|
if campaign is None:
|
||||||
raise RuntimeError("No campaign for this battle tie")
|
raise DomainError("No campaign for this battle tie")
|
||||||
if battle.player_1_id is None or battle.player_2_id is None:
|
if battle.player_1_id is None or battle.player_2_id is None:
|
||||||
raise RuntimeError("Missing player(s) in this battle context.")
|
raise DomainError("Missing player(s) in this battle context.")
|
||||||
p1 = campaign.participants[battle.player_1_id].war_participant_id
|
p1_id = campaign.campaign_to_war_part_id(battle.player_1_id)
|
||||||
p2 = campaign.participants[battle.player_2_id].war_participant_id
|
p2_id = campaign.campaign_to_war_part_id(battle.player_2_id)
|
||||||
if not TieResolver.can_tie_be_resolved(war, context, [p1, p2]):
|
if not TieResolver.can_tie_be_resolved(war, context, [p1_id, p2_id]):
|
||||||
war.events.append(
|
war.events.append(
|
||||||
TieResolved(None, ContextType.BATTLE, battle.sector_id)
|
TieResolved(None, ContextType.BATTLE, battle.sector_id)
|
||||||
)
|
)
|
||||||
|
|
@ -118,7 +54,7 @@ class TieResolver:
|
||||||
TieContext(
|
TieContext(
|
||||||
context_type=ContextType.BATTLE,
|
context_type=ContextType.BATTLE,
|
||||||
context_id=battle.sector_id,
|
context_id=battle.sector_id,
|
||||||
participants=[p1, p2],
|
participants=[p1_id, p2_id],
|
||||||
score_value=None,
|
score_value=None,
|
||||||
score_kind=None,
|
score_kind=None,
|
||||||
)
|
)
|
||||||
|
|
@ -240,8 +176,8 @@ class TieResolver:
|
||||||
ContextType.WAR,
|
ContextType.WAR,
|
||||||
war.id,
|
war.id,
|
||||||
[],
|
[],
|
||||||
score_value,
|
score_value=score_value,
|
||||||
ScoreKind.VP,
|
score_kind=ScoreKind.VP,
|
||||||
)
|
)
|
||||||
if TieResolver.is_tie_resolved(war, context):
|
if TieResolver.is_tie_resolved(war, context):
|
||||||
continue
|
continue
|
||||||
|
|
@ -427,15 +363,15 @@ class TieResolver:
|
||||||
context,
|
context,
|
||||||
context.participants,
|
context.participants,
|
||||||
)
|
)
|
||||||
# confirmed draw if non had bid
|
# confirmed draw if none had bid
|
||||||
if not active:
|
if not active:
|
||||||
war.events.append(
|
war.events.append(
|
||||||
TieResolved(
|
TieResolved(
|
||||||
None,
|
None,
|
||||||
context.context_type,
|
context.context_type,
|
||||||
context.context_id,
|
context.context_id,
|
||||||
context.score_value,
|
score_value=context.score_value,
|
||||||
context.objective_id,
|
objective_id=context.objective_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
@ -446,8 +382,8 @@ class TieResolver:
|
||||||
None,
|
None,
|
||||||
context.context_type,
|
context.context_type,
|
||||||
context.context_id,
|
context.context_id,
|
||||||
context.score_value,
|
score_value=context.score_value,
|
||||||
context.objective_id,
|
objective_id=context.objective_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@ class War:
|
||||||
new_events: List[WarEvent] = []
|
new_events: List[WarEvent] = []
|
||||||
for ev in self.events:
|
for ev in self.events:
|
||||||
if isinstance(ev, (TieResolved)):
|
if isinstance(ev, (TieResolved)):
|
||||||
|
# FIXME cancel TieResolved + TokenSpent
|
||||||
if ev.context_type == ContextType.BATTLE:
|
if ev.context_type == ContextType.BATTLE:
|
||||||
battle = self.get_battle(ev.context_id)
|
battle = self.get_battle(ev.context_id)
|
||||||
campaign = self.get_campaign_by_sector(battle.sector_id)
|
campaign = self.get_campaign_by_sector(battle.sector_id)
|
||||||
|
|
|
||||||
|
|
@ -77,10 +77,12 @@ class TieResolved(WarEvent):
|
||||||
context_id: str,
|
context_id: str,
|
||||||
score_value: int | None = None,
|
score_value: int | None = None,
|
||||||
objective_id: str | None = None,
|
objective_id: str | None = None,
|
||||||
|
sector_id: str | None = None,
|
||||||
):
|
):
|
||||||
super().__init__(participant_id, context_type, context_id)
|
super().__init__(participant_id, context_type, context_id)
|
||||||
self.score_value = score_value
|
self.score_value = score_value
|
||||||
self.objective_id = objective_id
|
self.objective_id = objective_id
|
||||||
|
self.sector_id = sector_id
|
||||||
|
|
||||||
def toDict(self) -> Dict[str, Any]:
|
def toDict(self) -> Dict[str, Any]:
|
||||||
d = super().toDict()
|
d = super().toDict()
|
||||||
|
|
@ -88,6 +90,7 @@ class TieResolved(WarEvent):
|
||||||
{
|
{
|
||||||
"score_value": self.score_value or None,
|
"score_value": self.score_value or None,
|
||||||
"objective_id": self.objective_id or None,
|
"objective_id": self.objective_id or None,
|
||||||
|
"sector_id": self.sector_id or None,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return d
|
return d
|
||||||
|
|
@ -100,6 +103,7 @@ class TieResolved(WarEvent):
|
||||||
data["context_id"],
|
data["context_id"],
|
||||||
JsonHelper.none_if_empty(data["score_value"]),
|
JsonHelper.none_if_empty(data["score_value"]),
|
||||||
JsonHelper.none_if_empty(data["objective_id"]),
|
JsonHelper.none_if_empty(data["objective_id"]),
|
||||||
|
JsonHelper.none_if_empty(data["sector_id"]),
|
||||||
)
|
)
|
||||||
return cls._base_fromDict(ev, data)
|
return cls._base_fromDict(ev, data)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -340,7 +340,8 @@
|
||||||
"context_id": "79accf7c-2d93-4ac3-b747-e7092bfe3feb",
|
"context_id": "79accf7c-2d93-4ac3-b747-e7092bfe3feb",
|
||||||
"timestamp": "2026-02-26T16:11:44.346337",
|
"timestamp": "2026-02-26T16:11:44.346337",
|
||||||
"score_value": null,
|
"score_value": null,
|
||||||
"objective_id": null
|
"objective_id": null,
|
||||||
|
"sector_id": null
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"is_over": false
|
"is_over": false
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue