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

@ -40,26 +40,30 @@ class Battle:
def set_comment(self, new_comment: str | None) -> None:
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]:
return {
"sector_id": self.sector_id,
"player_1_id": self.player_1_id,
"player_2_id": self.player_2_id,
"winner_id": self.winner_id,
"score": self.score,
"victory_condition": self.victory_condition,
"comment": self.comment,
"player_1_id": self.player_1_id or None,
"player_2_id": self.player_2_id or None,
"winner_id": self.winner_id or None,
"score": self.score or None,
"victory_condition": self.victory_condition or None,
"comment": self.comment or None,
}
@staticmethod
def fromDict(data: Dict[str, Any]) -> Battle:
battle = Battle(
data["sector_id"],
data.get("player_1_id"),
data.get("player_2_id"),
data.get("player_1_id") or None,
data.get("player_2_id") or None,
)
battle.winner_id = data.get("winner_id")
battle.score = data.get("score")
battle.victory_condition = data.get("victory_condition")
battle.comment = data.get("comment")
battle.winner_id = data.get("winner_id") or None
battle.score = data.get("score") or None
battle.victory_condition = data.get("victory_condition") or None
battle.comment = data.get("comment") or None
return battle

View file

@ -146,7 +146,12 @@ class Campaign:
)
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:
sect = Sector(name, round_id, major_id, minor_id, influence_id)
self.sectors[sect.id] = sect
@ -168,10 +173,10 @@ class Campaign:
sector_id: str,
*,
name: str,
round_id: str,
major_id: str,
minor_id: str,
influence_id: str,
round_id: str | None,
major_id: str | None,
minor_id: str | None,
influence_id: str | None,
) -> None:
sect = self.get_sector(sector_id)
old_round_id = sect.round_id
@ -308,6 +313,9 @@ class Campaign:
# 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:
rnd = self.get_round(round_id)
return rnd.create_battle(sector_id)

View file

@ -33,17 +33,17 @@ class Choice:
def toDict(self) -> Dict[str, Any]:
return {
"participant_id": self.participant_id,
"priority_sector_id": self.priority_sector_id,
"secondary_sector_id": self.secondary_sector_id,
"comment": self.comment,
"priority_sector_id": self.priority_sector_id or None,
"secondary_sector_id": self.secondary_sector_id or None,
"comment": self.comment or None,
}
@staticmethod
def fromDict(data: Dict[str, Any]) -> Choice:
choice = Choice(
data["participant_id"],
data.get("priority_sector_id"),
data.get("secondary_sector_id"),
data.get("priority_sector_id") or None,
data.get("secondary_sector_id") or None,
)
choice.comment = data.get("comment")
choice.comment = data.get("comment") or None
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
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)
return war.add_objective(name, description)
@ -195,7 +197,7 @@ class Model:
raise KeyError("Objective not found")
def update_objective(
self, objective_id: str, *, name: str, description: str
self, objective_id: str, *, name: str, description: str | None
) -> None:
war = self.get_war_by_objective(objective_id)
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:
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:
war = self.get_war_by_war_participant(participant_id)
war.update_war_participant(participant_id, faction=faction)
@ -290,10 +297,10 @@ class Model:
self,
campaign_id: str,
name: str,
round_id: str,
major_id: str,
minor_id: str,
influence_id: str,
round_id: str | None,
major_id: str | None,
minor_id: str | None,
influence_id: str | None,
) -> Sector:
camp = self.get_campaign(campaign_id)
return camp.add_sector(name, round_id, major_id, minor_id, influence_id)
@ -312,10 +319,10 @@ class Model:
sector_id: str,
*,
name: str,
round_id: str,
major_id: str,
minor_id: str,
influence_id: str,
round_id: str | None,
major_id: str | None,
minor_id: str | None,
influence_id: str | None,
) -> None:
war = self.get_war_by_sector(sector_id)
war.update_sector(
@ -343,11 +350,6 @@ class Model:
camp = self.get_campaign(camp_id)
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
def get_campaign_participant(self, participant_id: str) -> CampaignParticipant:
for war in self.wars.values():

View file

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

View file

@ -104,6 +104,11 @@ class Round:
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:
if sector_id not in self.battles:
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.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.minor_objective_id: str | None = minor_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:
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
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
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
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
def toDict(self) -> Dict[str, Any]:
@ -44,11 +44,11 @@ class Sector:
"id": self.id,
"name": self.name,
"round_id": self.round_id,
"major_objective_id": self.major_objective_id,
"minor_objective_id": self.minor_objective_id,
"influence_objective_id": self.influence_objective_id,
"mission": self.mission,
"description": self.description,
"major_objective_id": self.major_objective_id or None,
"minor_objective_id": self.minor_objective_id or None,
"influence_objective_id": self.influence_objective_id or None,
"mission": self.mission or None,
"description": self.description or None,
}
@staticmethod
@ -56,11 +56,11 @@ class Sector:
sec = Sector(
data["name"],
data["round_id"],
data.get("major_objective_id"),
data.get("minor_objective_id"),
data.get("influence_objective_id"),
data.get("major_objective_id") or None,
data.get("minor_objective_id") or None,
data.get("influence_objective_id") or None,
)
sec.set_id(data["id"])
sec.mission = data.get("mission")
sec.description = data.get("description")
sec.mission = data.get("mission") or None
sec.description = data.get("description") or None
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
def add_objective(self, name: str, description: str) -> Objective:
def add_objective(self, name: str, description: str | None) -> Objective:
obj = Objective(name, description)
self.objectives[obj.id] = obj
return obj
@ -104,7 +104,7 @@ class War:
return obj.name if obj else ""
def update_objective(
self, objective_id: str, *, name: str, description: str
self, objective_id: str, *, name: str, description: str | None
) -> None:
obj = self.get_objective(objective_id)
obj.set_name(name)
@ -173,6 +173,9 @@ class War:
def get_default_campaign_values(self) -> Dict[str, Any]:
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:
if month is None:
month = self.get_default_campaign_values()["month"]
@ -247,10 +250,10 @@ class War:
sector_id: str,
*,
name: str,
round_id: str,
major_id: str,
minor_id: str,
influence_id: str,
round_id: str | None,
major_id: str | None,
minor_id: str | None,
influence_id: str | None,
) -> None:
camp = self.get_campaign_by_sector(sector_id)
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 typing import Any, Dict
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:
@ -8,6 +14,7 @@ class WarParticipant:
self.id: str = str(uuid4())
self.player_id: str = player_id # ref to Model.players
self.faction: str = faction
self.events: list[WarEvent] = []
def set_id(self, new_id: str) -> None:
self.id = new_id
@ -33,3 +40,16 @@ class WarParticipant:
)
part.set_id(data["id"])
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)