wip close round/campaign/war + refacto json None

This commit is contained in:
Maxime Réaux 2026-02-11 19:22:43 +01:00
parent 4c8086caf4
commit 6cbb7c6534
26 changed files with 474 additions and 108 deletions

View file

@ -28,3 +28,10 @@ class RefreshScope(Enum):
CAMPAIGN_DETAILS = auto() CAMPAIGN_DETAILS = auto()
ROUND_DETAILS = auto() ROUND_DETAILS = auto()
CURRENT_SELECTION_DETAILS = auto() CURRENT_SELECTION_DETAILS = auto()
class ContextType(StrEnum):
WAR = "war"
CAMPAIGN = "campaign"
CHOICE = "choice"
BATTLE = "battle"

View file

@ -52,10 +52,13 @@ class AppController:
self.view.influenceToken.toggled.connect(self.wars.set_influence_token) self.view.influenceToken.toggled.connect(self.wars.set_influence_token)
self.view.addObjectiveBtn.clicked.connect(self.wars.add_objective) self.view.addObjectiveBtn.clicked.connect(self.wars.add_objective)
self.view.addWarParticipantBtn.clicked.connect(self.wars.add_war_participant) self.view.addWarParticipantBtn.clicked.connect(self.wars.add_war_participant)
self.view.endWarBtn.clicked.connect(self.wars.close_war)
self.view.addSectorBtn.clicked.connect(self.campaigns.add_sector) self.view.addSectorBtn.clicked.connect(self.campaigns.add_sector)
self.view.addCampaignParticipantBtn.clicked.connect( self.view.addCampaignParticipantBtn.clicked.connect(
self.campaigns.add_campaign_participant self.campaigns.add_campaign_participant
) )
self.view.endCampaignBtn.clicked.connect(self.campaigns.close_campaign)
self.view.endRoundBtn.clicked.connect(self.rounds.close_round)
self.view.on_edit_item = self.edit_item self.view.on_edit_item = self.edit_item
self.view.on_delete_item = self.delete_item self.view.on_delete_item = self.delete_item

View file

@ -13,6 +13,7 @@ from warchron.controller.dtos import (
SectorDTO, SectorDTO,
RoundDTO, RoundDTO,
) )
from warchron.model.closure_service import ClosureService
from warchron.view.campaign_dialog import CampaignDialog from warchron.view.campaign_dialog import CampaignDialog
from warchron.view.campaign_participant_dialog import CampaignParticipantDialog from warchron.view.campaign_participant_dialog import CampaignParticipantDialog
from warchron.view.sector_dialog import SectorDialog from warchron.view.sector_dialog import SectorDialog
@ -50,6 +51,7 @@ class CampaignController:
for p in camp_parts for p in camp_parts
] ]
self.app.view.display_campaign_participants(participants_for_display) self.app.view.display_campaign_participants(participants_for_display)
self.app.view.endCampaignBtn.setEnabled(not camp.is_over)
def _validate_campaign_inputs(self, name: str, month: int) -> bool: def _validate_campaign_inputs(self, name: str, month: int) -> bool:
if not name.strip(): if not name.strip():
@ -99,6 +101,28 @@ class CampaignController:
return return
self.app.model.update_campaign(campaign_id, name=name, month=month) self.app.model.update_campaign(campaign_id, name=name, month=month)
def close_campaign(self) -> None:
campaign_id = self.app.navigation.selected_campaign_id
if not campaign_id:
return
camp = self.app.model.get_campaign(campaign_id)
if camp.is_over:
return
try:
ties = ClosureService.close_campaign(camp)
except RuntimeError as e:
QMessageBox.warning(self.app.view, "Cannot close campaign", str(e))
return
if ties:
QMessageBox.information(
self.app.view,
"Tie detected",
"Campaign has unresolved ties.",
)
return
self.app.is_dirty = True
self.app.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS)
# Campaign participant methods # Campaign participant methods
def add_campaign_participant(self) -> None: def add_campaign_participant(self) -> None:
@ -148,7 +172,12 @@ class CampaignController:
# Sector methods # Sector methods
def _validate_sector_inputs( def _validate_sector_inputs(
self, name: str, round_id: str, major_id: str, minor_id: str, influence_id: str self,
name: str,
round_id: str | None,
major_id: str | None,
minor_id: str | None,
influence_id: str | None,
) -> bool: ) -> bool:
if not name.strip(): if not name.strip():

View file

@ -29,7 +29,7 @@ class WarDTO:
class ObjectiveDTO: class ObjectiveDTO:
id: str id: str
name: str name: str
description: str description: str | None
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@ -63,9 +63,9 @@ class SectorDTO:
id: str id: str
name: str name: str
round_index: int | None round_index: int | None
major: str major: str | None
minor: str minor: str | None
influence: str influence: str | None
@dataclass @dataclass
@ -78,8 +78,8 @@ class RoundDTO:
class ChoiceDTO: class ChoiceDTO:
id: str id: str
participant_name: str participant_name: str
priority_sector: str priority_sector: str | None
secondary_sector: str secondary_sector: str | None
comment: str | None comment: str | None
@ -87,8 +87,8 @@ class ChoiceDTO:
class BattleDTO: class BattleDTO:
id: str id: str
sector_name: str sector_name: str
player_1: str player_1: str | None
player_2: str player_2: str | None
winner: str | None winner: str | None
score: str | None score: str | None
victory_condition: str | None victory_condition: str | None

View file

@ -1,13 +1,13 @@
from typing import List, TYPE_CHECKING from typing import List, TYPE_CHECKING
from PyQt6.QtWidgets import QDialog from PyQt6.QtWidgets import QDialog, QMessageBox
from warchron.constants import ItemType, RefreshScope from warchron.constants import ItemType, RefreshScope
if TYPE_CHECKING: if TYPE_CHECKING:
from warchron.controller.app_controller import AppController from warchron.controller.app_controller import AppController
from warchron.controller.dtos import ParticipantOption, SectorDTO, ChoiceDTO, BattleDTO from warchron.controller.dtos import ParticipantOption, SectorDTO, ChoiceDTO, BattleDTO
from warchron.model.closure_service import ClosureService
from warchron.view.choices_dialog import ChoicesDialog from warchron.view.choices_dialog import ChoicesDialog
from warchron.view.battles_dialog import BattlesDialog from warchron.view.battles_dialog import BattlesDialog
@ -92,6 +92,7 @@ class RoundController:
) )
) )
self.app.view.display_round_battles(battles_for_display) self.app.view.display_round_battles(battles_for_display)
self.app.view.endRoundBtn.setEnabled(not rnd.is_over)
def add_round(self) -> None: def add_round(self) -> None:
if not self.app.navigation.selected_campaign_id: if not self.app.navigation.selected_campaign_id:
@ -102,6 +103,28 @@ class RoundController:
RefreshScope.WARS_TREE, item_type=ItemType.ROUND, item_id=rnd.id RefreshScope.WARS_TREE, item_type=ItemType.ROUND, item_id=rnd.id
) )
def close_round(self) -> None:
round_id = self.app.navigation.selected_round_id
if not round_id:
return
rnd = self.app.model.get_round(round_id)
if rnd.is_over:
return
try:
ties = ClosureService.close_round(rnd)
except RuntimeError as e:
QMessageBox.warning(self.app.view, "Cannot close round", str(e))
return
if ties:
QMessageBox.information(
self.app.view,
"Tie detected",
"Round has unresolved ties. Resolution system not implemented yet.",
)
return
self.app.is_dirty = True
self.app.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS)
# Choice methods # Choice methods
def edit_round_choice(self, choice_id: str) -> None: def edit_round_choice(self, choice_id: str) -> None:

View file

@ -11,6 +11,7 @@ from warchron.controller.dtos import (
WarParticipantDTO, WarParticipantDTO,
ObjectiveDTO, ObjectiveDTO,
) )
from warchron.model.closure_service import ClosureService
from warchron.view.war_dialog import WarDialog from warchron.view.war_dialog import WarDialog
from warchron.view.objective_dialog import ObjectiveDialog from warchron.view.objective_dialog import ObjectiveDialog
from warchron.view.war_participant_dialog import WarParticipantDialog from warchron.view.war_participant_dialog import WarParticipantDialog
@ -44,6 +45,7 @@ class WarController:
for p in war_parts for p in war_parts
] ]
self.app.view.display_war_participants(participants_for_display) self.app.view.display_war_participants(participants_for_display)
self.app.view.endWarBtn.setEnabled(not war.is_over)
def _validate_war_inputs(self, name: str, year: int) -> bool: def _validate_war_inputs(self, name: str, year: int) -> bool:
if not name.strip(): if not name.strip():
@ -86,6 +88,28 @@ class WarController:
return return
self.app.model.update_war(war_id, name=name, year=year) self.app.model.update_war(war_id, name=name, year=year)
def close_war(self) -> None:
war_id = self.app.navigation.selected_war_id
if not war_id:
return
war = self.app.model.get_war(war_id)
if war.is_over:
return
try:
ties = ClosureService.close_war(war)
except RuntimeError as e:
QMessageBox.warning(self.app.view, "Cannot close war", str(e))
return
if ties:
QMessageBox.information(
self.app.view,
"Tie detected",
"War has unresolved ties.",
)
return
self.app.is_dirty = True
self.app.navigation.refresh(RefreshScope.CURRENT_SELECTION_DETAILS)
def set_major_value(self, value: int) -> None: def set_major_value(self, value: int) -> None:
war_id = self.app.navigation.selected_war_id war_id = self.app.navigation.selected_war_id
if not war_id: if not war_id:
@ -109,7 +133,7 @@ class WarController:
# Objective methods # Objective methods
def _validate_objective_inputs(self, name: str, description: str) -> bool: def _validate_objective_inputs(self, name: str, description: str | None) -> bool:
if not name.strip(): if not name.strip():
QMessageBox.warning( QMessageBox.warning(
self.app.view, "Invalid name", "Objective name cannot be empty." self.app.view, "Invalid name", "Objective name cannot be empty."

View file

@ -40,26 +40,30 @@ class Battle:
def set_comment(self, new_comment: str | None) -> None: def set_comment(self, new_comment: str | None) -> None:
self.comment = new_comment self.comment = new_comment
# TODO improve draw detection
def is_draw(self) -> bool:
return self.winner_id is None and self.score is not None
def toDict(self) -> Dict[str, Any]: def toDict(self) -> Dict[str, Any]:
return { return {
"sector_id": self.sector_id, "sector_id": self.sector_id,
"player_1_id": self.player_1_id, "player_1_id": self.player_1_id or None,
"player_2_id": self.player_2_id, "player_2_id": self.player_2_id or None,
"winner_id": self.winner_id, "winner_id": self.winner_id or None,
"score": self.score, "score": self.score or None,
"victory_condition": self.victory_condition, "victory_condition": self.victory_condition or None,
"comment": self.comment, "comment": self.comment or None,
} }
@staticmethod @staticmethod
def fromDict(data: Dict[str, Any]) -> Battle: def fromDict(data: Dict[str, Any]) -> Battle:
battle = Battle( battle = Battle(
data["sector_id"], data["sector_id"],
data.get("player_1_id"), data.get("player_1_id") or None,
data.get("player_2_id"), data.get("player_2_id") or None,
) )
battle.winner_id = data.get("winner_id") battle.winner_id = data.get("winner_id") or None
battle.score = data.get("score") battle.score = data.get("score") or None
battle.victory_condition = data.get("victory_condition") battle.victory_condition = data.get("victory_condition") or None
battle.comment = data.get("comment") battle.comment = data.get("comment") or None
return battle return battle

View file

@ -146,7 +146,12 @@ class Campaign:
) )
def add_sector( def add_sector(
self, name: str, round_id: str, major_id: str, minor_id: str, influence_id: str self,
name: str,
round_id: str | None,
major_id: str | None,
minor_id: str | None,
influence_id: str | None,
) -> Sector: ) -> Sector:
sect = Sector(name, round_id, major_id, minor_id, influence_id) sect = Sector(name, round_id, major_id, minor_id, influence_id)
self.sectors[sect.id] = sect self.sectors[sect.id] = sect
@ -168,10 +173,10 @@ class Campaign:
sector_id: str, sector_id: str,
*, *,
name: str, name: str,
round_id: str, round_id: str | None,
major_id: str, major_id: str | None,
minor_id: str, minor_id: str | None,
influence_id: str, influence_id: str | None,
) -> None: ) -> None:
sect = self.get_sector(sector_id) sect = self.get_sector(sector_id)
old_round_id = sect.round_id old_round_id = sect.round_id
@ -308,6 +313,9 @@ class Campaign:
# Battle methods # Battle methods
def all_rounds_finished(self) -> bool:
return all(r.is_over for r in self.rounds)
def create_battle(self, round_id: str, sector_id: str) -> Battle: def create_battle(self, round_id: str, sector_id: str) -> Battle:
rnd = self.get_round(round_id) rnd = self.get_round(round_id)
return rnd.create_battle(sector_id) return rnd.create_battle(sector_id)

View file

@ -33,17 +33,17 @@ class Choice:
def toDict(self) -> Dict[str, Any]: def toDict(self) -> Dict[str, Any]:
return { return {
"participant_id": self.participant_id, "participant_id": self.participant_id,
"priority_sector_id": self.priority_sector_id, "priority_sector_id": self.priority_sector_id or None,
"secondary_sector_id": self.secondary_sector_id, "secondary_sector_id": self.secondary_sector_id or None,
"comment": self.comment, "comment": self.comment or None,
} }
@staticmethod @staticmethod
def fromDict(data: Dict[str, Any]) -> Choice: def fromDict(data: Dict[str, Any]) -> Choice:
choice = Choice( choice = Choice(
data["participant_id"], data["participant_id"],
data.get("priority_sector_id"), data.get("priority_sector_id") or None,
data.get("secondary_sector_id"), data.get("secondary_sector_id") or None,
) )
choice.comment = data.get("comment") choice.comment = data.get("comment") or None
return choice return choice

View file

@ -0,0 +1,83 @@
from __future__ import annotations
from typing import List
from warchron.constants import ContextType
from warchron.model.tie_manager import ResolutionContext
from warchron.model.war import War
from warchron.model.campaign import Campaign
from warchron.model.round import Round
class ClosureService:
@staticmethod
def close_round(round: Round) -> List[ResolutionContext]:
if not round.all_battles_finished():
raise RuntimeError("All battles must be finished to close their round")
ties = []
for battle in round.battles.values():
if battle.is_draw():
participants: list[str] = []
if battle.player_1_id is not None:
participants.append(battle.player_1_id)
if battle.player_2_id is not None:
participants.append(battle.player_2_id)
ties.append(
ResolutionContext(
context_type=ContextType.BATTLE,
context_id=battle.sector_id,
# TODO ref to War.participants at some point
participant_ids=participants,
)
)
if ties:
return ties
round.is_over = True
return []
@staticmethod
def close_campaign(campaign: Campaign) -> List[ResolutionContext]:
if not campaign.all_rounds_finished():
raise RuntimeError("All rounds must be finished to close their campaign")
ties: List[ResolutionContext] = []
# for round in campaign.rounds:
# # compute score
# # if participants have same score
# ties.append(
# ResolutionContext(
# context_type=ContextType.CAMPAIGN,
# context_id=campaign.id,
# participant_ids=[
# # TODO ref to War.participants at some point
# campaign.participants[campaign_participant_id],
# campaign.participants[campaign_participant_id],
# ],
# )
# )
if ties:
return ties
campaign.is_over = True
return []
@staticmethod
def close_war(war: War) -> List[ResolutionContext]:
if not war.all_campaigns_finished():
raise RuntimeError("All campaigns must be finished to close their war")
ties: List[ResolutionContext] = []
# for campaign in war.campaigns:
# # compute score
# # if participants have same score
# ties.append(
# ResolutionContext(
# context_type=ContextType.WAR,
# context_id=war.id,
# participant_ids=[
# war.participants[war_participant_id],
# war.participants[war_participant_id],
# ],
# )
# )
if ties:
return ties
war.is_over = True
return []

View file

@ -0,0 +1,23 @@
from warchron.model.war import War
from warchron.model.campaign import Campaign
from warchron.model.battle import Battle
from warchron.model.war_event import InfluenceGained
class InfluenceService:
@staticmethod
def apply_battle_result(war: War, campaign: Campaign, battle: Battle) -> None:
if battle.winner_id is None:
return
sector = campaign.sectors[battle.sector_id]
# if sector grants influence
if sector.influence_objective_id and war.influence_token:
participant = war.participants[battle.winner_id]
participant.events.append(
InfluenceGained(
participant_id=participant.id,
amount=1,
source=f"battle:{battle.sector_id}",
)
)

View file

@ -182,7 +182,9 @@ class Model:
# Objective methods # Objective methods
def add_objective(self, war_id: str, name: str, description: str) -> Objective: def add_objective(
self, war_id: str, name: str, description: str | None
) -> Objective:
war = self.get_war(war_id) war = self.get_war(war_id)
return war.add_objective(name, description) return war.add_objective(name, description)
@ -195,7 +197,7 @@ class Model:
raise KeyError("Objective not found") raise KeyError("Objective not found")
def update_objective( def update_objective(
self, objective_id: str, *, name: str, description: str self, objective_id: str, *, name: str, description: str | None
) -> None: ) -> None:
war = self.get_war_by_objective(objective_id) war = self.get_war_by_objective(objective_id)
war.update_objective(objective_id, name=name, description=description) war.update_objective(objective_id, name=name, description=description)
@ -229,6 +231,11 @@ class Model:
def get_player_from_war_participant(self, war_part: WarParticipant) -> Player: def get_player_from_war_participant(self, war_part: WarParticipant) -> Player:
return self.get_player(war_part.player_id) return self.get_player(war_part.player_id)
def get_participant_name(self, participant_id: str) -> str:
war = self.get_war_by_war_participant(participant_id)
war_part = war.get_war_participant(participant_id)
return self.players[war_part.player_id].name
def update_war_participant(self, participant_id: str, *, faction: str) -> None: def update_war_participant(self, participant_id: str, *, faction: str) -> None:
war = self.get_war_by_war_participant(participant_id) war = self.get_war_by_war_participant(participant_id)
war.update_war_participant(participant_id, faction=faction) war.update_war_participant(participant_id, faction=faction)
@ -290,10 +297,10 @@ class Model:
self, self,
campaign_id: str, campaign_id: str,
name: str, name: str,
round_id: str, round_id: str | None,
major_id: str, major_id: str | None,
minor_id: str, minor_id: str | None,
influence_id: str, influence_id: str | None,
) -> Sector: ) -> Sector:
camp = self.get_campaign(campaign_id) camp = self.get_campaign(campaign_id)
return camp.add_sector(name, round_id, major_id, minor_id, influence_id) return camp.add_sector(name, round_id, major_id, minor_id, influence_id)
@ -312,10 +319,10 @@ class Model:
sector_id: str, sector_id: str,
*, *,
name: str, name: str,
round_id: str, round_id: str | None,
major_id: str, major_id: str | None,
minor_id: str, minor_id: str | None,
influence_id: str, influence_id: str | None,
) -> None: ) -> None:
war = self.get_war_by_sector(sector_id) war = self.get_war_by_sector(sector_id)
war.update_sector( war.update_sector(
@ -343,11 +350,6 @@ class Model:
camp = self.get_campaign(camp_id) camp = self.get_campaign(camp_id)
return camp.add_campaign_participant(player_id, leader, theme) return camp.add_campaign_participant(player_id, leader, theme)
def get_participant_name(self, participant_id: str) -> str:
war = self.get_war_by_war_participant(participant_id)
war_part = war.get_war_participant(participant_id)
return self.players[war_part.player_id].name
# TODO replace multiloops by internal has_* method # TODO replace multiloops by internal has_* method
def get_campaign_participant(self, participant_id: str) -> CampaignParticipant: def get_campaign_participant(self, participant_id: str) -> CampaignParticipant:
for war in self.wars.values(): for war in self.wars.values():

View file

@ -4,10 +4,10 @@ from uuid import uuid4
class Objective: class Objective:
def __init__(self, name: str, description: str): def __init__(self, name: str, description: str | None):
self.id: str = str(uuid4()) self.id: str = str(uuid4())
self.name: str = name self.name: str = name
self.description: str = description self.description: str | None = description
def set_id(self, new_id: str) -> None: def set_id(self, new_id: str) -> None:
self.id = new_id self.id = new_id
@ -15,18 +15,18 @@ class Objective:
def set_name(self, new_name: str) -> None: def set_name(self, new_name: str) -> None:
self.name = new_name self.name = new_name
def set_description(self, new_description: str) -> None: def set_description(self, new_description: str | None) -> None:
self.description = new_description self.description = new_description
def toDict(self) -> Dict[str, Any]: def toDict(self) -> Dict[str, Any]:
return { return {
"id": self.id, "id": self.id,
"name": self.name, "name": self.name,
"description": self.description, "description": self.description or None,
} }
@staticmethod @staticmethod
def fromDict(data: Dict[str, Any]) -> Objective: def fromDict(data: Dict[str, Any]) -> Objective:
obj = Objective(data["name"], data["description"]) obj = Objective(data["name"], data["description"] or None)
obj.set_id(data["id"]) obj.set_id(data["id"])
return obj return obj

View file

@ -104,6 +104,11 @@ class Round:
for bat in self.battles.values() for bat in self.battles.values()
) )
def all_battles_finished(self) -> bool:
return all(
b.winner_id is not None or b.is_draw() for b in self.battles.values()
)
def create_battle(self, sector_id: str) -> Battle: def create_battle(self, sector_id: str) -> Battle:
if sector_id not in self.battles: if sector_id not in self.battles:
battle = Battle(sector_id=sector_id, player_1_id=None, player_2_id=None) battle = Battle(sector_id=sector_id, player_1_id=None, player_2_id=None)

View file

@ -0,0 +1,39 @@
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from warchron.model.war import War
class ScoreService:
@staticmethod
def compute_victory_points_for_participant(war: "War", participant_id: str) -> int:
total = 0
for campaign in war.campaigns:
for round_ in campaign.rounds:
for battle in round_.battles.values():
if battle.winner_id == participant_id:
sector = campaign.sectors[battle.sector_id]
if sector.major_objective_id:
total += war.major_value
if sector.minor_objective_id:
total += war.minor_value
return total
@staticmethod
def compute_narrative_points_for_participant(
war: "War", participant_id: str
) -> dict[str, int]:
totals: dict[str, int] = {}
for obj_id in war.objectives:
totals[obj_id] = 0
for campaign in war.campaigns:
for round_ in campaign.rounds:
for battle in round_.battles.values():
if battle.winner_id == participant_id:
sector = campaign.sectors[battle.sector_id]
if sector.major_objective_id:
totals[sector.major_objective_id] += war.major_value
if sector.minor_objective_id:
totals[sector.minor_objective_id] += war.minor_value
return totals

View file

@ -14,7 +14,7 @@ class Sector:
): ):
self.id: str = str(uuid4()) self.id: str = str(uuid4())
self.name: str = name self.name: str = name
self.round_id: str | None = round_id self.round_id: str | None = round_id # ref to Campaign.rounds
self.major_objective_id: str | None = major_id # ref to War.objectives self.major_objective_id: str | None = major_id # ref to War.objectives
self.minor_objective_id: str | None = minor_id # ref to War.objectives self.minor_objective_id: str | None = minor_id # ref to War.objectives
self.influence_objective_id: str | None = influence_id # ref to War.objectives self.influence_objective_id: str | None = influence_id # ref to War.objectives
@ -27,16 +27,16 @@ class Sector:
def set_name(self, new_name: str) -> None: def set_name(self, new_name: str) -> None:
self.name = new_name self.name = new_name
def set_round(self, new_round_id: str) -> None: def set_round(self, new_round_id: str | None) -> None:
self.round_id = new_round_id self.round_id = new_round_id
def set_major(self, new_major_id: str) -> None: def set_major(self, new_major_id: str | None) -> None:
self.major_objective_id = new_major_id self.major_objective_id = new_major_id
def set_minor(self, new_minor_id: str) -> None: def set_minor(self, new_minor_id: str | None) -> None:
self.minor_objective_id = new_minor_id self.minor_objective_id = new_minor_id
def set_influence(self, new_influence_id: str) -> None: def set_influence(self, new_influence_id: str | None) -> None:
self.influence_objective_id = new_influence_id self.influence_objective_id = new_influence_id
def toDict(self) -> Dict[str, Any]: def toDict(self) -> Dict[str, Any]:
@ -44,11 +44,11 @@ class Sector:
"id": self.id, "id": self.id,
"name": self.name, "name": self.name,
"round_id": self.round_id, "round_id": self.round_id,
"major_objective_id": self.major_objective_id, "major_objective_id": self.major_objective_id or None,
"minor_objective_id": self.minor_objective_id, "minor_objective_id": self.minor_objective_id or None,
"influence_objective_id": self.influence_objective_id, "influence_objective_id": self.influence_objective_id or None,
"mission": self.mission, "mission": self.mission or None,
"description": self.description, "description": self.description or None,
} }
@staticmethod @staticmethod
@ -56,11 +56,11 @@ class Sector:
sec = Sector( sec = Sector(
data["name"], data["name"],
data["round_id"], data["round_id"],
data.get("major_objective_id"), data.get("major_objective_id") or None,
data.get("minor_objective_id"), data.get("minor_objective_id") or None,
data.get("influence_objective_id"), data.get("influence_objective_id") or None,
) )
sec.set_id(data["id"]) sec.set_id(data["id"])
sec.mission = data.get("mission") sec.mission = data.get("mission") or None
sec.description = data.get("description") sec.description = data.get("description") or None
return sec return sec

View file

@ -0,0 +1,46 @@
from warchron.model.war import War
from warchron.model.war_event import InfluenceSpent
class ResolutionContext:
def __init__(self, context_type: str, context_id: str, participant_ids: list[str]):
self.context_type = context_type
self.context_id = context_id
self.participant_ids = participant_ids
self.current_bids: dict[str, int] = {}
self.round_index: int = 0
self.is_resolved: bool = False
class TieResolver:
@staticmethod
def resolve(
war: War,
context: ResolutionContext,
bids: dict[str, int],
) -> str | None:
# verify available token for each player
for pid, amount in bids.items():
participant = war.participants[pid]
if participant.influence_tokens() < amount:
raise ValueError("Not enough influence tokens")
# apply spending
for pid, amount in bids.items():
if amount > 0:
war.participants[pid].events.append(
InfluenceSpent(
participant_id=pid,
amount=amount,
context=context.context_type,
)
)
# determine winner
max_bid = max(bids.values())
winners = [pid for pid, b in bids.items() if b == max_bid]
if len(winners) == 1:
context.is_resolved = True
return winners[0]
# persisting tie → None
return None

View file

@ -86,7 +86,7 @@ class War:
# Objective methods # Objective methods
def add_objective(self, name: str, description: str) -> Objective: def add_objective(self, name: str, description: str | None) -> Objective:
obj = Objective(name, description) obj = Objective(name, description)
self.objectives[obj.id] = obj self.objectives[obj.id] = obj
return obj return obj
@ -104,7 +104,7 @@ class War:
return obj.name if obj else "" return obj.name if obj else ""
def update_objective( def update_objective(
self, objective_id: str, *, name: str, description: str self, objective_id: str, *, name: str, description: str | None
) -> None: ) -> None:
obj = self.get_objective(objective_id) obj = self.get_objective(objective_id)
obj.set_name(name) obj.set_name(name)
@ -173,6 +173,9 @@ class War:
def get_default_campaign_values(self) -> Dict[str, Any]: def get_default_campaign_values(self) -> Dict[str, Any]:
return {"month": datetime.now().month} return {"month": datetime.now().month}
def all_campaigns_finished(self) -> bool:
return all(c.is_over for c in self.campaigns)
def add_campaign(self, name: str, month: int | None = None) -> Campaign: def add_campaign(self, name: str, month: int | None = None) -> Campaign:
if month is None: if month is None:
month = self.get_default_campaign_values()["month"] month = self.get_default_campaign_values()["month"]
@ -247,10 +250,10 @@ class War:
sector_id: str, sector_id: str,
*, *,
name: str, name: str,
round_id: str, round_id: str | None,
major_id: str, major_id: str | None,
minor_id: str, minor_id: str | None,
influence_id: str, influence_id: str | None,
) -> None: ) -> None:
camp = self.get_campaign_by_sector(sector_id) camp = self.get_campaign_by_sector(sector_id)
camp.update_sector( camp.update_sector(

View file

@ -0,0 +1,30 @@
from datetime import datetime
from uuid import uuid4
class WarEvent:
def __init__(self, participant_id: str):
self.id: str = str(uuid4())
self.participant_id: str = participant_id
self.timestamp: datetime = datetime.now()
class TieResolved(WarEvent):
def __init__(self, participant_id: str, context_type: str, context_id: str):
super().__init__(participant_id)
self.context_type = context_type # battle, round, campaign, war
self.context_id = context_id
class InfluenceGained(WarEvent):
def __init__(self, participant_id: str, amount: int, source: str):
super().__init__(participant_id)
self.amount = amount
self.source = source # "battle", "tie_resolution", etc.
class InfluenceSpent(WarEvent):
def __init__(self, participant_id: str, amount: int, context: str):
super().__init__(participant_id)
self.amount = amount
self.context = context # "battle_tie", "campaign_tie", etc.

View file

@ -1,6 +1,12 @@
from __future__ import annotations from __future__ import annotations
from typing import Any, Dict from typing import Any, Dict
from uuid import uuid4 from uuid import uuid4
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from warchron.model.war import War
from warchron.model.war_event import WarEvent, InfluenceSpent, InfluenceGained
from warchron.model.score_service import ScoreService
class WarParticipant: class WarParticipant:
@ -8,6 +14,7 @@ class WarParticipant:
self.id: str = str(uuid4()) self.id: str = str(uuid4())
self.player_id: str = player_id # ref to Model.players self.player_id: str = player_id # ref to Model.players
self.faction: str = faction self.faction: str = faction
self.events: list[WarEvent] = []
def set_id(self, new_id: str) -> None: def set_id(self, new_id: str) -> None:
self.id = new_id self.id = new_id
@ -33,3 +40,16 @@ class WarParticipant:
) )
part.set_id(data["id"]) part.set_id(data["id"])
return part return part
# Computed properties
def influence_tokens(self) -> int:
gained = sum(e.amount for e in self.events if isinstance(e, InfluenceGained))
spent = sum(e.amount for e in self.events if isinstance(e, InfluenceSpent))
return gained - spent
def victory_points(self, war: "War") -> int:
return ScoreService.compute_victory_points_for_participant(war, self.id)
def narrative_points(self, war: "War") -> Dict[str, int]:
return ScoreService.compute_narrative_points_for_participant(war, self.id)

View file

@ -49,20 +49,26 @@ class BattlesDialog(QDialog):
def get_sector_id(self) -> str: def get_sector_id(self) -> str:
return cast(str, self.ui.sectorComboBox.currentData()) return cast(str, self.ui.sectorComboBox.currentData())
def get_player_1_id(self) -> str: def get_player_1_id(self) -> str | None:
return cast(str, self.ui.player1ComboBox.currentData()) text = cast(str, self.ui.player1ComboBox.currentData())
return text if text else None
def get_player_2_id(self) -> str: def get_player_2_id(self) -> str | None:
return cast(str, self.ui.player2ComboBox.currentData()) text = cast(str, self.ui.player2ComboBox.currentData())
return text if text else None
def get_winner_id(self) -> str: def get_winner_id(self) -> str | None:
return cast(str, self.ui.winnerComboBox.currentData()) text = cast(str, self.ui.winnerComboBox.currentData())
return text if text else None
def get_score(self) -> str: def get_score(self) -> str | None:
return self.ui.score.text().strip() text = self.ui.score.text().strip()
return text if text else None
def get_victory_condition(self) -> str: def get_victory_condition(self) -> str | None:
return self.ui.victoryCondition.text().strip() text = self.ui.victoryCondition.text().strip()
return text if text else None
def get_comment(self) -> str: def get_comment(self) -> str | None:
return self.ui.battleComment.toPlainText().strip() text = self.ui.battleComment.toPlainText().strip()
return text if text else None

View file

@ -38,11 +38,14 @@ class ChoicesDialog(QDialog):
def get_participant_id(self) -> str: def get_participant_id(self) -> str:
return cast(str, self.ui.playerComboBox.currentData()) return cast(str, self.ui.playerComboBox.currentData())
def get_priority_id(self) -> str: def get_priority_id(self) -> str | None:
return cast(str, self.ui.priorityComboBox.currentData()) text = cast(str, self.ui.priorityComboBox.currentData())
return text if text else None
def get_secondary_id(self) -> str: def get_secondary_id(self) -> str | None:
return cast(str, self.ui.secondaryComboBox.currentData()) text = cast(str, self.ui.secondaryComboBox.currentData())
return text if text else None
def get_comment(self) -> str: def get_comment(self) -> str | None:
return self.ui.choiceComment.toPlainText().strip() text = self.ui.choiceComment.toPlainText().strip()
return text if text else None

View file

@ -20,5 +20,6 @@ class ObjectiveDialog(QDialog):
def get_objective_name(self) -> str: def get_objective_name(self) -> str:
return self.ui.objectiveName.text().strip() return self.ui.objectiveName.text().strip()
def get_objective_description(self) -> str: def get_objective_description(self) -> str | None:
return self.ui.objectiveDescription.toPlainText().strip() text = self.ui.objectiveDescription.toPlainText().strip()
return text if text else None

View file

@ -42,14 +42,21 @@ class SectorDialog(QDialog):
def get_sector_name(self) -> str: def get_sector_name(self) -> str:
return self.ui.sectorName.text().strip() return self.ui.sectorName.text().strip()
def get_round_id(self) -> str: def get_round_id(self) -> str | None:
return cast(str, self.ui.roundComboBox.currentData()) text = cast(str, self.ui.roundComboBox.currentData())
return text if text else None
def get_major_id(self) -> str: def get_major_id(self) -> str | None:
return cast(str, self.ui.majorComboBox.currentData()) text = cast(str, self.ui.majorComboBox.currentData())
return text if text else None
def get_minor_id(self) -> str: def get_minor_id(self) -> str | None:
return cast(str, self.ui.minorComboBox.currentData()) text = cast(str, self.ui.minorComboBox.currentData())
return text if text else None
def get_influence_id(self) -> str: def get_influence_id(self) -> str | None:
return cast(str, self.ui.influenceComboBox.currentData()) text = cast(str, self.ui.influenceComboBox.currentData())
return text if text else None
# TODO fix mission + description missing

View file

@ -369,7 +369,7 @@ class Ui_MainWindow(object):
spacerItem15 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) spacerItem15 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum)
self.horizontalLayout_13.addItem(spacerItem15) self.horizontalLayout_13.addItem(spacerItem15)
self.resolvePairingBtn = QtWidgets.QPushButton(parent=self.pageRound) self.resolvePairingBtn = QtWidgets.QPushButton(parent=self.pageRound)
self.resolvePairingBtn.setEnabled(True) self.resolvePairingBtn.setEnabled(False)
self.resolvePairingBtn.setObjectName("resolvePairingBtn") self.resolvePairingBtn.setObjectName("resolvePairingBtn")
self.horizontalLayout_13.addWidget(self.resolvePairingBtn) self.horizontalLayout_13.addWidget(self.resolvePairingBtn)
self.verticalLayout_8.addLayout(self.horizontalLayout_13) self.verticalLayout_8.addLayout(self.horizontalLayout_13)
@ -486,7 +486,7 @@ class Ui_MainWindow(object):
self.retranslateUi(MainWindow) self.retranslateUi(MainWindow)
self.tabWidget.setCurrentIndex(1) self.tabWidget.setCurrentIndex(1)
self.selectedDetailsStack.setCurrentIndex(1) self.selectedDetailsStack.setCurrentIndex(3)
QtCore.QMetaObject.connectSlotsByName(MainWindow) QtCore.QMetaObject.connectSlotsByName(MainWindow)
def retranslateUi(self, MainWindow): def retranslateUi(self, MainWindow):

View file

@ -181,7 +181,7 @@
</widget> </widget>
<widget class="QStackedWidget" name="selectedDetailsStack"> <widget class="QStackedWidget" name="selectedDetailsStack">
<property name="currentIndex"> <property name="currentIndex">
<number>1</number> <number>3</number>
</property> </property>
<widget class="QWidget" name="pageEmpty"> <widget class="QWidget" name="pageEmpty">
<layout class="QVBoxLayout" name="verticalLayout_4"> <layout class="QVBoxLayout" name="verticalLayout_4">
@ -873,7 +873,7 @@
<item> <item>
<widget class="QPushButton" name="resolvePairingBtn"> <widget class="QPushButton" name="resolvePairingBtn">
<property name="enabled"> <property name="enabled">
<bool>true</bool> <bool>false</bool>
</property> </property>
<property name="text"> <property name="text">
<string>Resolve pairing</string> <string>Resolve pairing</string>