compute campaign points from rounds
This commit is contained in:
parent
45c1d69a3f
commit
7c9c941864
9 changed files with 165 additions and 75 deletions
16
README.md
16
README.md
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue