2026-01-30 10:52:19 +01:00
|
|
|
from __future__ import annotations
|
2026-02-02 10:41:16 +01:00
|
|
|
from uuid import uuid4
|
2026-03-17 11:16:47 +01:00
|
|
|
from typing import Any, Dict, List, TYPE_CHECKING
|
2026-02-02 10:41:16 +01:00
|
|
|
|
2026-03-17 11:16:47 +01:00
|
|
|
if TYPE_CHECKING:
|
|
|
|
|
from warchron.model.campaign import Campaign
|
2026-03-18 09:26:43 +01:00
|
|
|
from warchron.model.war import War
|
2026-03-19 15:10:48 +01:00
|
|
|
from warchron.constants import ContextType
|
2026-03-19 11:25:40 +01:00
|
|
|
from warchron.model.exception import (
|
|
|
|
|
ForbiddenOperation,
|
|
|
|
|
DomainError,
|
|
|
|
|
RequiresConfirmation,
|
|
|
|
|
)
|
2026-02-05 08:42:38 +01:00
|
|
|
from warchron.model.choice import Choice
|
|
|
|
|
from warchron.model.battle import Battle
|
|
|
|
|
|
2026-01-21 07:43:04 +01:00
|
|
|
|
2026-01-19 18:55:07 +01:00
|
|
|
class Round:
|
2026-02-04 16:10:53 +01:00
|
|
|
def __init__(self) -> None:
|
2026-01-28 16:25:40 +01:00
|
|
|
self.id: str = str(uuid4())
|
2026-02-04 16:10:53 +01:00
|
|
|
self.choices: Dict[str, Choice] = {}
|
|
|
|
|
self.battles: Dict[str, Battle] = {}
|
2026-01-28 16:25:40 +01:00
|
|
|
self.is_over: bool = False
|
2026-03-17 11:16:47 +01:00
|
|
|
self._campaign: Campaign | None = None # private link
|
2026-01-19 18:55:07 +01:00
|
|
|
|
2026-03-18 09:26:43 +01:00
|
|
|
@property
|
|
|
|
|
def campaign(self) -> "Campaign":
|
|
|
|
|
if self._campaign is None:
|
|
|
|
|
raise RuntimeError("Round is not linked to a Campaign")
|
|
|
|
|
return self._campaign
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def war(self) -> "War":
|
|
|
|
|
return self.campaign.war
|
|
|
|
|
|
2026-02-04 16:10:53 +01:00
|
|
|
def set_id(self, new_id: str) -> None:
|
2026-01-21 07:43:04 +01:00
|
|
|
self.id = new_id
|
2026-01-19 18:55:07 +01:00
|
|
|
|
2026-02-04 16:10:53 +01:00
|
|
|
def set_state(self, new_state: bool) -> None:
|
2026-01-19 18:55:07 +01:00
|
|
|
self.is_over = new_state
|
|
|
|
|
|
2026-02-04 16:10:53 +01:00
|
|
|
def toDict(self) -> Dict[str, Any]:
|
2026-01-19 18:55:07 +01:00
|
|
|
return {
|
2026-01-21 08:31:48 +01:00
|
|
|
"id": self.id,
|
2026-02-06 09:59:54 +01:00
|
|
|
"choices": [c.toDict() for c in self.choices.values()],
|
|
|
|
|
"battles": [b.toDict() for b in self.battles.values()],
|
2026-02-02 10:41:16 +01:00
|
|
|
"is_over": self.is_over,
|
2026-01-19 18:55:07 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
2026-02-04 16:10:53 +01:00
|
|
|
def fromDict(data: Dict[str, Any]) -> Round:
|
2026-01-21 08:31:48 +01:00
|
|
|
rnd = Round()
|
|
|
|
|
rnd.set_id(data["id"])
|
2026-02-06 09:59:54 +01:00
|
|
|
for c in data.get("choices", []):
|
|
|
|
|
choice = Choice.fromDict(c)
|
|
|
|
|
rnd.choices[choice.participant_id] = choice
|
|
|
|
|
for b in data.get("battles", []):
|
|
|
|
|
battle = Battle.fromDict(b)
|
|
|
|
|
rnd.battles[battle.sector_id] = battle
|
2026-01-21 08:31:48 +01:00
|
|
|
rnd.set_state(data.get("is_over", False))
|
2026-01-30 10:52:19 +01:00
|
|
|
return rnd
|
2026-02-02 10:41:16 +01:00
|
|
|
|
|
|
|
|
# Choice methods
|
2026-01-30 10:52:19 +01:00
|
|
|
|
2026-01-30 15:32:44 +01:00
|
|
|
def get_choice(self, participant_id: str) -> Choice | None:
|
2026-01-30 10:52:19 +01:00
|
|
|
return self.choices.get(participant_id)
|
2026-02-02 10:41:16 +01:00
|
|
|
|
2026-02-05 16:17:18 +01:00
|
|
|
def has_choice_with_sector(self, sector_id: str) -> bool:
|
|
|
|
|
return any(
|
|
|
|
|
choice.priority_sector_id == sector_id
|
|
|
|
|
or choice.secondary_sector_id == sector_id
|
|
|
|
|
for choice in self.choices.values()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def has_choice_with_participant(self, participant_id: str) -> bool:
|
|
|
|
|
return any(
|
|
|
|
|
choice.participant_id == participant_id for choice in self.choices.values()
|
|
|
|
|
)
|
|
|
|
|
|
2026-01-30 15:32:44 +01:00
|
|
|
def create_choice(self, participant_id: str) -> Choice:
|
2026-02-13 11:38:59 +01:00
|
|
|
if self.is_over:
|
|
|
|
|
raise ForbiddenOperation("Can't create choice in a closed round.")
|
2026-01-30 15:32:44 +01:00
|
|
|
if participant_id not in self.choices:
|
|
|
|
|
choice = Choice(
|
2026-02-02 10:41:16 +01:00
|
|
|
participant_id=participant_id,
|
|
|
|
|
priority_sector_id=None,
|
|
|
|
|
secondary_sector_id=None,
|
2026-01-30 15:32:44 +01:00
|
|
|
)
|
|
|
|
|
self.choices[participant_id] = choice
|
|
|
|
|
return self.choices[participant_id]
|
2026-01-30 10:52:19 +01:00
|
|
|
|
2026-02-02 10:41:16 +01:00
|
|
|
def update_choice(
|
|
|
|
|
self,
|
|
|
|
|
participant_id: str,
|
|
|
|
|
priority_sector_id: str | None,
|
|
|
|
|
secondary_sector_id: str | None,
|
|
|
|
|
comment: str | None,
|
2026-02-04 16:10:53 +01:00
|
|
|
) -> None:
|
2026-02-13 11:38:59 +01:00
|
|
|
if self.is_over:
|
|
|
|
|
raise ForbiddenOperation("Can't update choice in a closed round.")
|
2026-01-30 18:55:39 +01:00
|
|
|
choice = self.get_choice(participant_id)
|
2026-02-04 16:10:53 +01:00
|
|
|
if choice:
|
2026-03-19 12:03:49 +01:00
|
|
|
if self.has_battle_with_participant(participant_id) and (
|
|
|
|
|
priority_sector_id != choice.priority_sector_id
|
|
|
|
|
or secondary_sector_id != choice.secondary_sector_id
|
|
|
|
|
):
|
|
|
|
|
raise ForbiddenOperation(
|
|
|
|
|
"Can't update choice already assigned to battle."
|
|
|
|
|
)
|
2026-02-04 16:10:53 +01:00
|
|
|
choice.set_priority(priority_sector_id)
|
|
|
|
|
choice.set_secondary(secondary_sector_id)
|
|
|
|
|
choice.set_comment(comment)
|
2026-01-30 18:55:39 +01:00
|
|
|
|
2026-02-05 16:17:18 +01:00
|
|
|
def clear_sector_references(self, sector_id: str) -> None:
|
|
|
|
|
for choice in self.choices.values():
|
2026-03-17 11:16:47 +01:00
|
|
|
trigger_revert_ties = False
|
2026-02-05 16:17:18 +01:00
|
|
|
if choice.priority_sector_id == sector_id:
|
|
|
|
|
choice.priority_sector_id = None
|
2026-03-17 11:16:47 +01:00
|
|
|
trigger_revert_ties = True
|
2026-02-05 16:17:18 +01:00
|
|
|
if choice.secondary_sector_id == sector_id:
|
|
|
|
|
choice.secondary_sector_id = None
|
2026-03-17 11:16:47 +01:00
|
|
|
trigger_revert_ties = True
|
|
|
|
|
if trigger_revert_ties:
|
2026-03-18 09:26:43 +01:00
|
|
|
self.war.revert_choice_ties(self.id, sector_id=sector_id)
|
2026-02-05 16:17:18 +01:00
|
|
|
|
2026-02-04 16:10:53 +01:00
|
|
|
def remove_choice(self, participant_id: str) -> None:
|
2026-03-18 09:26:43 +01:00
|
|
|
if participant_id not in self.choices:
|
|
|
|
|
return
|
2026-02-13 11:38:59 +01:00
|
|
|
if self.is_over:
|
|
|
|
|
raise ForbiddenOperation("Can't remove choice in a closed round.")
|
2026-03-19 11:25:40 +01:00
|
|
|
if self.has_battle_with_participant(participant_id):
|
|
|
|
|
raise ForbiddenOperation("Can't remove choice already assigned to battle.")
|
2026-03-18 09:26:43 +01:00
|
|
|
self.war.revert_choice_ties(
|
|
|
|
|
self.id,
|
|
|
|
|
participants=[self.campaign.campaign_to_war_part_id(participant_id)],
|
|
|
|
|
)
|
2026-01-30 15:32:44 +01:00
|
|
|
del self.choices[participant_id]
|
|
|
|
|
|
2026-02-02 10:41:16 +01:00
|
|
|
# Battle methods
|
2026-01-30 18:55:39 +01:00
|
|
|
|
|
|
|
|
def get_battle(self, sector_id: str) -> Battle | None:
|
|
|
|
|
return self.battles.get(sector_id)
|
2026-02-02 10:41:16 +01:00
|
|
|
|
2026-03-19 09:02:22 +01:00
|
|
|
def get_battle_for_participant(
|
|
|
|
|
self,
|
|
|
|
|
campaign_participant_id: str,
|
|
|
|
|
) -> Battle | None:
|
|
|
|
|
for battle in self.battles.values():
|
|
|
|
|
if (
|
|
|
|
|
battle.player_1_id == campaign_participant_id
|
|
|
|
|
or battle.player_2_id == campaign_participant_id
|
|
|
|
|
):
|
|
|
|
|
return battle
|
|
|
|
|
return None
|
|
|
|
|
|
2026-02-05 16:17:18 +01:00
|
|
|
def has_battle_with_sector(self, sector_id: str) -> bool:
|
|
|
|
|
return any(bat.sector_id == sector_id for bat in self.battles.values())
|
|
|
|
|
|
|
|
|
|
def has_battle_with_participant(self, participant_id: str) -> bool:
|
|
|
|
|
return any(
|
|
|
|
|
bat.player_1_id == participant_id or bat.player_2_id == participant_id
|
|
|
|
|
for bat in self.battles.values()
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-13 11:38:59 +01:00
|
|
|
def has_finished_battle(self) -> bool:
|
|
|
|
|
return any(b.is_finished() for b in self.battles.values())
|
|
|
|
|
|
2026-02-11 19:22:43 +01:00
|
|
|
def all_battles_finished(self) -> bool:
|
|
|
|
|
return all(
|
|
|
|
|
b.winner_id is not None or b.is_draw() for b in self.battles.values()
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-11 11:44:57 +01:00
|
|
|
def get_battles_with_places(self) -> List[Battle]:
|
|
|
|
|
return [
|
|
|
|
|
battle for battle in self.battles.values() if battle.get_available_places()
|
|
|
|
|
]
|
|
|
|
|
|
2026-01-30 18:55:39 +01:00
|
|
|
def create_battle(self, sector_id: str) -> Battle:
|
2026-02-13 11:38:59 +01:00
|
|
|
if self.is_over:
|
|
|
|
|
raise ForbiddenOperation("Can't create battle in a closed round.")
|
2026-01-30 18:55:39 +01:00
|
|
|
if sector_id not in self.battles:
|
2026-02-02 10:41:16 +01:00
|
|
|
battle = Battle(sector_id=sector_id, player_1_id=None, player_2_id=None)
|
2026-01-30 18:55:39 +01:00
|
|
|
self.battles[sector_id] = battle
|
|
|
|
|
return self.battles[sector_id]
|
|
|
|
|
|
2026-02-02 10:41:16 +01:00
|
|
|
def update_battle(
|
|
|
|
|
self,
|
|
|
|
|
sector_id: str,
|
|
|
|
|
player_1_id: str | None,
|
|
|
|
|
player_2_id: str | None,
|
|
|
|
|
winner_id: str | None,
|
|
|
|
|
score: str | None,
|
|
|
|
|
victory_condition: str | None,
|
|
|
|
|
comment: str | None,
|
2026-02-04 16:10:53 +01:00
|
|
|
) -> None:
|
2026-03-19 15:10:48 +01:00
|
|
|
from warchron.model.tie_manager import TieResolver
|
2026-03-19 11:25:40 +01:00
|
|
|
|
2026-02-13 11:38:59 +01:00
|
|
|
if self.is_over:
|
|
|
|
|
raise ForbiddenOperation("Can't update battle in a closed round.")
|
2026-01-30 18:55:39 +01:00
|
|
|
bat = self.get_battle(sector_id)
|
2026-03-19 11:25:40 +01:00
|
|
|
if not bat:
|
|
|
|
|
raise DomainError(f"No battle found for sector {sector_id}")
|
|
|
|
|
|
|
|
|
|
def apply_update() -> None:
|
2026-02-04 16:10:53 +01:00
|
|
|
bat.set_player_1(player_1_id)
|
|
|
|
|
bat.set_player_2(player_2_id)
|
|
|
|
|
bat.set_winner(winner_id)
|
|
|
|
|
bat.set_score(score)
|
|
|
|
|
bat.set_victory_condition(victory_condition)
|
|
|
|
|
bat.set_comment(comment)
|
|
|
|
|
|
2026-03-19 11:25:40 +01:00
|
|
|
if bat.player_1_id == player_1_id and bat.player_2_id == player_2_id:
|
|
|
|
|
apply_update()
|
|
|
|
|
return
|
|
|
|
|
affected_choices: List[Choice] = []
|
|
|
|
|
affected_players: List[str | None] = list(
|
|
|
|
|
{
|
|
|
|
|
player_1_id,
|
|
|
|
|
player_2_id,
|
|
|
|
|
bat.player_1_id,
|
|
|
|
|
bat.player_2_id,
|
|
|
|
|
}
|
|
|
|
|
- {None}
|
|
|
|
|
)
|
|
|
|
|
for choice in self.choices.values():
|
|
|
|
|
for player in affected_players:
|
|
|
|
|
if (
|
|
|
|
|
player
|
|
|
|
|
and self.has_choice_with_participant(player)
|
2026-03-19 15:10:48 +01:00
|
|
|
and TieResolver.participant_spent_token(
|
2026-03-19 11:25:40 +01:00
|
|
|
self.war,
|
2026-03-19 15:10:48 +01:00
|
|
|
ContextType.CHOICE,
|
2026-03-19 11:25:40 +01:00
|
|
|
self.id,
|
|
|
|
|
sector_id,
|
|
|
|
|
self.campaign.campaign_to_war_part_id(player),
|
|
|
|
|
)
|
|
|
|
|
):
|
|
|
|
|
affected_choices.append(choice)
|
|
|
|
|
if not affected_choices:
|
|
|
|
|
apply_update()
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
def cleanup_and_update() -> None:
|
|
|
|
|
self.clear_sector_references(sector_id)
|
|
|
|
|
apply_update()
|
|
|
|
|
|
|
|
|
|
raise RequiresConfirmation(
|
|
|
|
|
"Changing the player(s) of this sector will affect choices.\n"
|
|
|
|
|
"Choices will be cleared and their tokens and tie-breaks will be deleted.\n"
|
|
|
|
|
"Do you want to continue?",
|
|
|
|
|
action=cleanup_and_update,
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-05 16:17:18 +01:00
|
|
|
def clear_participant_references(self, participant_id: str) -> None:
|
|
|
|
|
for battle in self.battles.values():
|
2026-03-17 11:16:47 +01:00
|
|
|
trigger_revert_ties = False
|
2026-02-05 16:17:18 +01:00
|
|
|
if battle.player_1_id == participant_id:
|
|
|
|
|
battle.player_1_id = None
|
2026-03-17 11:16:47 +01:00
|
|
|
trigger_revert_ties = True
|
2026-02-05 16:17:18 +01:00
|
|
|
if battle.player_2_id == participant_id:
|
|
|
|
|
battle.player_2_id = None
|
2026-03-17 11:16:47 +01:00
|
|
|
trigger_revert_ties = True
|
2026-02-05 16:17:18 +01:00
|
|
|
if battle.winner_id == participant_id:
|
|
|
|
|
battle.winner_id = None
|
2026-03-17 11:16:47 +01:00
|
|
|
if trigger_revert_ties:
|
2026-03-18 09:26:43 +01:00
|
|
|
self.war.revert_battle_ties(
|
|
|
|
|
self.id,
|
|
|
|
|
participants=[
|
|
|
|
|
self.campaign.campaign_to_war_part_id(participant_id)
|
|
|
|
|
],
|
|
|
|
|
)
|
2026-02-05 16:17:18 +01:00
|
|
|
|
2026-02-04 16:10:53 +01:00
|
|
|
def remove_battle(self, sector_id: str) -> None:
|
2026-03-18 09:26:43 +01:00
|
|
|
if sector_id not in self.battles:
|
|
|
|
|
return
|
2026-02-13 11:38:59 +01:00
|
|
|
if self.is_over:
|
|
|
|
|
raise ForbiddenOperation("Can't remove battle in a closed round.")
|
|
|
|
|
bat = self.battles[sector_id]
|
|
|
|
|
if bat and bat.is_finished():
|
|
|
|
|
raise ForbiddenOperation("Can't remove finished battle.")
|
2026-03-18 09:26:43 +01:00
|
|
|
self.war.revert_battle_ties(self.id, sector_id=sector_id)
|
2026-01-30 18:55:39 +01:00
|
|
|
del self.battles[sector_id]
|