compute campaign points from rounds

This commit is contained in:
Maxime Réaux 2026-02-19 14:17:42 +01:00
parent 45c1d69a3f
commit 7c9c941864
9 changed files with 165 additions and 75 deletions

View file

@ -7,16 +7,12 @@ A simple local app to track players' campaigns for tabletop wargames.
### Main logic ### Main logic
Manage a list of players to sign them up to be selectable for war(s) and campaign(s). Manage a list of players to sign them up to be selectable for war(s) and campaign(s).
A "war" year contains several "campaign" events which contain several "battle" games organised in successive rounds. A war year offers various objectives along several campaigns. Wars are independent.
Battle results determine campaign score which determines the war score. A campaign event presents customisable sectors to fight on during battle rounds. Campaigns are successive and are used for historical tie-breaker.
Wars are independent. 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).
### Design notes 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.
* Players are global identities
* Influence tokens are scoped to a war
* Campaign order enables historical tie-breakers
* Effects are generic → future-proof
## Installation ## Installation

View file

@ -2,21 +2,22 @@ from typing import List, TYPE_CHECKING
from PyQt6.QtWidgets import QMessageBox, QDialog from PyQt6.QtWidgets import QMessageBox, QDialog
from warchron.constants import RefreshScope from warchron.constants import RefreshScope, ContextType
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 ( from warchron.controller.dtos import (
ParticipantOption, ParticipantOption,
ObjectiveDTO, ObjectiveDTO,
CampaignParticipantDTO,
SectorDTO, SectorDTO,
RoundDTO, RoundDTO,
CampaignParticipantScoreDTO,
) )
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
from warchron.model.sector import Sector from warchron.model.sector import Sector
from warchron.model.closure_service import ClosureService from warchron.model.closure_service import ClosureService
from warchron.model.score_service import ScoreService
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
@ -45,17 +46,28 @@ class CampaignController:
for sect in sectors for sect in sectors
] ]
self.app.view.display_campaign_sectors(sectors_for_display) self.app.view.display_campaign_sectors(sectors_for_display)
camp_parts = camp.get_all_campaign_participants() scores = ScoreService.compute_scores(war, ContextType.CAMPAIGN, campaign_id)
participants_for_display: List[CampaignParticipantDTO] = [ rows: List[CampaignParticipantScoreDTO] = []
CampaignParticipantDTO( for camp_part in camp.get_all_campaign_participants():
id=p.id, war_part_id = camp_part.war_participant_id
player_name=self.app.model.get_participant_name(p.war_participant_id), war_part = war.get_war_participant(war_part_id)
leader=p.leader or "", player_name = self.app.model.get_player_name(war_part.player_id)
theme=p.theme or "", score = scores[war_part_id]
rows.append(
CampaignParticipantScoreDTO(
campaign_participant_id=camp_part.id,
war_participant_id=war_part_id,
player_name=player_name,
leader=camp_part.leader or "",
theme=camp_part.theme or "",
victory_points=score.victory_points,
narrative_points=dict(score.narrative_points),
)
) )
for p in camp_parts objectives = [
ObjectiveDTO(o.id, o.name, o.description) for o in war.get_all_objectives()
] ]
self.app.view.display_campaign_participants(participants_for_display) self.app.view.display_campaign_participants(rows, objectives)
self.app.view.endCampaignBtn.setEnabled(not camp.is_over) 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:
@ -189,7 +201,7 @@ class CampaignController:
self.app.view, "Invalid name", "Sector name cannot be empty." self.app.view, "Invalid name", "Sector name cannot be empty."
) )
return False return False
# allow same objectives in different fields? # TODO allow same objectives in different fields?
return True return True
def create_sector(self) -> Sector | None: def create_sector(self) -> Sector | None:

View file

@ -114,3 +114,22 @@ class TieContext:
context_type: ContextType context_type: ContextType
context_id: str context_id: str
participants: List[str] # war_participant_ids participants: List[str] # war_participant_ids
@dataclass(frozen=True, slots=True)
class ParticipantScoreDTO:
participant_id: str
player_name: str
victory_points: int
narrative_points: dict[str, int]
@dataclass(frozen=True, slots=True)
class CampaignParticipantScoreDTO:
campaign_participant_id: str
war_participant_id: str
player_name: str
leader: str
theme: str
victory_points: int
narrative_points: dict[str, int]

View file

@ -43,12 +43,13 @@ class Battle:
def is_draw(self) -> bool: def is_draw(self) -> bool:
if self.winner_id is not None: if self.winner_id is not None:
return False return False
# Case 1: score entered → interpreted as unresolved outcome
if self.score and self.score.strip(): if self.score and self.score.strip():
return True return True
# Case 2: explicit draw mention
if self.victory_condition: if self.victory_condition:
if "draw" in self.victory_condition.casefold(): if any(
keyword in (self.victory_condition or "").casefold()
for keyword in ["draw", "tie", "square"]
):
return True return True
return False return False

View file

@ -284,6 +284,22 @@ class Campaign:
return rnd return rnd
raise KeyError(f"Round {round_id} not found") raise KeyError(f"Round {round_id} not found")
def get_round_index(self, round_id: str | None) -> int | None:
if round_id is None:
return None
for index, rnd in enumerate(self.rounds, start=1):
if rnd.id == round_id:
return index
raise KeyError("Round not found in campaign")
# TODO replace multiloops by internal has_* method
def get_round_by_battle(self, sector_id: str) -> Round:
for rnd in self.rounds:
for bat in rnd.battles.values():
if bat.sector_id == sector_id:
return rnd
raise KeyError(f"Battle {sector_id} not found in any Round")
def get_all_rounds(self) -> List[Round]: def get_all_rounds(self) -> List[Round]:
return list(self.rounds) return list(self.rounds)
@ -309,14 +325,6 @@ class Campaign:
if rnd: if rnd:
self.rounds.remove(rnd) self.rounds.remove(rnd)
def get_round_index(self, round_id: str | None) -> int | None:
if round_id is None:
return None
for index, rnd in enumerate(self.rounds, start=1):
if rnd.id == round_id:
return index
raise KeyError("Round not found in campaign")
# Choice methods # Choice methods
def create_choice(self, round_id: str, participant_id: str) -> Choice: def create_choice(self, round_id: str, participant_id: str) -> Choice:

View file

@ -32,10 +32,9 @@ class ClosureService:
) )
if already_granted: if already_granted:
return return
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.participants[battle.winner_id].war_participant_id
else:
base_winner = None
effective_winner = TieResolver.get_effective_winner_id( effective_winner = TieResolver.get_effective_winner_id(
war, war,
ContextType.BATTLE, ContextType.BATTLE,

View file

@ -1,45 +1,77 @@
from typing import Dict, TYPE_CHECKING from typing import Dict, Iterator
from dataclasses import dataclass, field
if TYPE_CHECKING: from warchron.constants import ContextType
from warchron.model.war import War from warchron.model.tie_manager import TieResolver
from warchron.model.war import War
from warchron.model.battle import Battle
@dataclass(slots=True)
class ParticipantScore:
victory_points: int = 0
narrative_points: Dict[str, int] = field(default_factory=dict)
class ScoreService: class ScoreService:
@staticmethod @staticmethod
def compute_victory_points_for_participant(war: "War", participant_id: str) -> int: def _get_battles_for_context(
total = 0 war: War, context_type: ContextType, context_id: str
for campaign in war.campaigns: ) -> Iterator[Battle]:
for round_ in campaign.rounds: if context_type == ContextType.WAR:
for battle in round_.battles.values(): for camp in war.campaigns:
if battle.winner_id == participant_id: for rnd in camp.rounds:
sector = campaign.sectors[battle.sector_id] if not rnd.is_over:
if sector.major_objective_id: continue
total += war.major_value yield from rnd.battles.values()
if sector.minor_objective_id:
total += war.minor_value elif context_type == ContextType.CAMPAIGN:
return total campaign = war.get_campaign(context_id)
for rnd in campaign.rounds:
if not rnd.is_over:
continue
yield from rnd.battles.values()
elif context_type == ContextType.BATTLE:
battle = war.get_battle(context_id)
campaign = war.get_campaign_by_sector(battle.sector_id)
rnd = campaign.get_round_by_battle(context_id)
if rnd and rnd.is_over:
yield battle
@staticmethod @staticmethod
def compute_narrative_points_for_participant( def compute_scores(
war: "War", participant_id: str war: War, context_type: ContextType, context_id: str
) -> Dict[str, int]: ) -> Dict[str, ParticipantScore]:
totals: Dict[str, int] = {} scores = {
for obj_id in war.objectives: pid: ParticipantScore(
totals[obj_id] = 0 narrative_points={obj_id: 0 for obj_id in war.objectives}
for campaign in war.campaigns: )
for round_ in campaign.rounds: for pid in war.participants
for battle in round_.battles.values(): }
if battle.winner_id == participant_id: battles = ScoreService._get_battles_for_context(war, context_type, context_id)
sector = campaign.sectors[battle.sector_id] for battle in battles:
if sector.major_objective_id: base_winner = None
totals[sector.major_objective_id] += war.major_value if battle.winner_id is not None:
if sector.minor_objective_id: campaign = war.get_campaign_by_campaign_participant(battle.winner_id)
totals[sector.minor_objective_id] += war.minor_value if campaign is not None:
return totals base_winner = campaign.participants[
battle.winner_id
# def compute_round_results(round) ].war_participant_id
winner = TieResolver.get_effective_winner_id(
# def compute_campaign_winner(campaign) war, ContextType.BATTLE, battle.sector_id, base_winner
)
# def compute_war_winner(war) if winner is None:
continue
scores[winner].victory_points += 1
sector = war.get_sector(battle.sector_id)
if sector.major_objective_id:
scores[winner].narrative_points[
sector.major_objective_id
] += war.major_value
if sector.minor_objective_id:
scores[winner].narrative_points[
sector.minor_objective_id
] += war.minor_value
return scores

View file

@ -413,6 +413,15 @@ class War:
# Battle methods # Battle methods
# TODO replace multiloops by internal has_* method
def get_battle(self, battle_id: str) -> Battle:
for camp in self.campaigns:
for rnd in camp.rounds:
for bat in rnd.battles.values():
if bat.sector_id == battle_id:
return bat
raise KeyError("Round not found")
def create_battle(self, round_id: str, sector_id: str) -> Battle: def create_battle(self, round_id: str, sector_id: str) -> Battle:
camp = self.get_campaign_by_round(round_id) camp = self.get_campaign_by_round(round_id)
if camp is not None: if camp is not None:

View file

@ -15,10 +15,10 @@ from warchron.controller.dtos import (
WarDTO, WarDTO,
WarParticipantDTO, WarParticipantDTO,
ObjectiveDTO, ObjectiveDTO,
CampaignParticipantDTO,
SectorDTO, SectorDTO,
ChoiceDTO, ChoiceDTO,
BattleDTO, BattleDTO,
CampaignParticipantScoreDTO,
) )
from warchron.view.helpers import ( from warchron.view.helpers import (
format_campaign_label, format_campaign_label,
@ -464,19 +464,33 @@ class View(QtWidgets.QMainWindow, Ui_MainWindow):
table.resizeColumnsToContents() table.resizeColumnsToContents()
def display_campaign_participants( def display_campaign_participants(
self, participants: List[CampaignParticipantDTO] self,
participants: List[CampaignParticipantScoreDTO],
objectives: List[ObjectiveDTO],
) -> None: ) -> None:
table = self.campaignParticipantsTable table = self.campaignParticipantsTable
table.clearContents() table.clearContents()
base_cols = ["Player", "Leader", "Theme", "Victory"]
headers = base_cols + [obj.name for obj in objectives]
table.setColumnCount(len(headers))
table.setHorizontalHeaderLabels(headers)
table.setRowCount(len(participants)) table.setRowCount(len(participants))
for row, part in enumerate(participants): for row, part in enumerate(participants):
name_item = QtWidgets.QTableWidgetItem(part.player_name) name_item = QtWidgets.QTableWidgetItem(part.player_name)
lead_item = QtWidgets.QTableWidgetItem(part.leader) lead_item = QtWidgets.QTableWidgetItem(part.leader)
theme_item = QtWidgets.QTableWidgetItem(part.theme) theme_item = QtWidgets.QTableWidgetItem(part.theme)
name_item.setData(Qt.ItemDataRole.UserRole, part.id) VP_item = QtWidgets.QTableWidgetItem(str(part.victory_points))
name_item.setData(Qt.ItemDataRole.UserRole, part.campaign_participant_id)
table.setItem(row, 0, name_item) table.setItem(row, 0, name_item)
table.setItem(row, 1, lead_item) table.setItem(row, 1, lead_item)
table.setItem(row, 2, theme_item) table.setItem(row, 2, theme_item)
table.setItem(row, 3, VP_item)
col = 4
for obj in objectives:
value = part.narrative_points.get(obj.id, 0)
NP_item = QtWidgets.QTableWidgetItem(str(value))
table.setItem(row, col, NP_item)
col += 1
table.resizeColumnsToContents() table.resizeColumnsToContents()
# Round page # Round page