detect and resolve battle tie with influence_token

This commit is contained in:
Maxime Réaux 2026-02-17 16:37:36 +01:00
parent 115ddf8d50
commit 818d2886f4
23 changed files with 808 additions and 172 deletions

View file

@ -19,17 +19,17 @@ class Battle:
self.victory_condition: str | None = None
self.comment: str | None = None
def set_id(self, new_id: str) -> None:
self.sector_id = new_id
def set_sector(self, new_sector_id: str) -> None:
self.sector_id = new_sector_id
def set_player_1(self, new_player_id: str | None) -> None:
self.player_1_id = new_player_id
def set_player_1(self, new_camp_part_id: str | None) -> None:
self.player_1_id = new_camp_part_id
def set_player_2(self, new_player_id: str | None) -> None:
self.player_2_id = new_player_id
def set_player_2(self, new_camp_part_id: str | None) -> None:
self.player_2_id = new_camp_part_id
def set_winner(self, new_player_id: str | None) -> None:
self.winner_id = new_player_id
def set_winner(self, new_camp_part_id: str | None) -> None:
self.winner_id = new_camp_part_id
def set_score(self, new_score: str | None) -> None:
self.score = new_score

View file

@ -15,8 +15,8 @@ class CampaignParticipant:
def set_id(self, new_id: str) -> None:
self.id = new_id
def set_war_participant(self, new_participant: str) -> None:
self.war_participant_id = new_participant
def set_war_participant(self, new_war_part_id: str) -> None:
self.war_participant_id = new_war_part_id
def set_leader(self, new_faction: str) -> None:
self.leader = new_faction

View file

@ -18,8 +18,8 @@ class Choice:
)
self.comment: str | None = None
def set_id(self, new_id: str) -> None:
self.participant_id = new_id
def set_participant(self, new_camp_part_id: str) -> None:
self.participant_id = new_camp_part_id
def set_priority(self, new_priority_id: str | None) -> None:
self.priority_sector_id = new_priority_id

View file

@ -2,44 +2,69 @@ from __future__ import annotations
from typing import List
from warchron.constants import ContextType
from warchron.model.tie_manager import ResolutionContext
from warchron.model.exception import ForbiddenOperation
from warchron.model.tie_manager import TieResolver
from warchron.model.war_event import InfluenceGained
from warchron.model.war import War
from warchron.model.campaign import Campaign
from warchron.model.round import Round
from warchron.model.battle import Battle
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 []
# Round methods
@staticmethod
def close_campaign(campaign: Campaign) -> List[ResolutionContext]:
def check_round_closable(round: Round) -> None:
if round.is_over:
raise ForbiddenOperation("Round already closed")
if not round.all_battles_finished():
raise ForbiddenOperation(
"All battles must be finished to close their round"
)
@staticmethod
def apply_battle_outcomes(war: War, campaign: Campaign, battle: Battle) -> None:
already_granted = any(
isinstance(e, InfluenceGained) and e.source == f"battle:{battle.sector_id}"
for e in war.events
)
if already_granted:
return
if battle.winner_id is not None:
base_winner = campaign.participants[battle.winner_id].war_participant_id
else:
base_winner = None
effective_winner = TieResolver.get_effective_winner_id(
war,
ContextType.BATTLE,
battle.sector_id,
base_winner,
)
if effective_winner is None:
return
sector = campaign.sectors[battle.sector_id]
if sector.influence_objective_id and war.influence_token:
war.events.append(
InfluenceGained(
participant_id=effective_winner,
amount=1,
source=f"battle:{battle.sector_id}",
)
)
@staticmethod
def finalize_round(round: Round) -> None:
round.is_over = True
# Campaign methods
@staticmethod
def close_campaign(campaign: Campaign) -> List[str]:
if not campaign.all_rounds_finished():
raise RuntimeError("All rounds must be finished to close their campaign")
ties: List[ResolutionContext] = []
ties: List[str] = []
# for round in campaign.rounds:
# # compute score
# # if participants have same score
@ -59,11 +84,13 @@ class ClosureService:
campaign.is_over = True
return []
# War methods
@staticmethod
def close_war(war: War) -> List[ResolutionContext]:
def close_war(war: War) -> List[str]:
if not war.all_campaigns_finished():
raise RuntimeError("All campaigns must be finished to close their war")
ties: List[ResolutionContext] = []
ties: List[str] = []
# for campaign in war.campaigns:
# # compute score
# # if participants have same score

View file

@ -1,19 +0,0 @@
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from warchron.model.war import War
from warchron.model.war import War
from warchron.model.war import War
from warchron.model.closure_service import ClosureService
class RoundClosureWorkflow:
def close_round(self, round_id):
rnd = repo.get_round(round_id)
ties = ClosureService.close_round(rnd)
repo.save()
return ties

View file

@ -1,23 +0,0 @@
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

@ -1,4 +1,4 @@
from typing import TYPE_CHECKING
from typing import Dict, TYPE_CHECKING
if TYPE_CHECKING:
from warchron.model.war import War
@ -23,8 +23,8 @@ class ScoreService:
@staticmethod
def compute_narrative_points_for_participant(
war: "War", participant_id: str
) -> dict[str, int]:
totals: dict[str, int] = {}
) -> Dict[str, int]:
totals: Dict[str, int] = {}
for obj_id in war.objectives:
totals[obj_id] = 0
for campaign in war.campaigns:

View file

@ -1,46 +1,108 @@
from typing import List, Dict
from warchron.constants import ContextType
from warchron.model.exception import ForbiddenOperation
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
from warchron.model.round import Round
from warchron.model.battle import Battle
from warchron.model.war_event import InfluenceSpent, TieResolved
class TieResolver:
@staticmethod
def resolve(
def find_round_ties(round: Round, war: War) -> List[Battle]:
ties = []
for battle in round.battles.values():
if not battle.is_draw():
continue
resolved = any(
isinstance(e, TieResolved)
and e.context_type == ContextType.BATTLE
and e.context_id == battle.sector_id
for e in war.events
)
if not resolved:
ties.append(battle)
return ties
@staticmethod
def apply_bids(
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,
)
context_type: ContextType,
context_id: str,
bids: Dict[str, bool], # war_participant_id -> spend?
) -> None:
for war_part_id, spend in bids.items():
if not spend:
continue
if war.get_influence_tokens(war_part_id) < 1:
raise ForbiddenOperation("Not enough tokens")
war.events.append(
InfluenceSpent(
participant_id=war_part_id,
amount=1,
context_type=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
)
@staticmethod
def try_tie_break(
war: War,
context_type: ContextType,
context_id: str,
participants: List[str], # war_participant_ids
) -> bool:
spent: Dict[str, int] = {}
for war_part_id in participants:
spent[war_part_id] = sum(
e.amount
for e in war.events
if isinstance(e, InfluenceSpent)
and e.participant_id == war_part_id
and e.context_type == context_type
)
values = set(spent.values())
if values == {0}: # no bid = confirmed draw
war.events.append(
TieResolved(
participant_id=None,
context_type=context_type,
context_id=context_id,
)
)
return True
if len(values) == 1: # tie again, continue
return False
winner = max(spent.items(), key=lambda item: item[1])[0]
war.events.append(
TieResolved(
participant_id=winner,
context_type=context_type,
context_id=context_id,
)
)
return True
@staticmethod
def can_tie_be_resolved(war: War, participants: List[str]) -> bool:
return any(war.get_influence_tokens(pid) > 0 for pid in participants)
@staticmethod
def get_effective_winner_id(
war: War,
context_type: ContextType,
context_id: str,
base_winner_id: str | None,
) -> str | None:
if base_winner_id is not None:
return base_winner_id
for ev in reversed(war.events):
if (
isinstance(ev, TieResolved)
and ev.context_type == context_type
and ev.context_id == context_id
):
return ev.participant_id # None if confirmed draw
return None

View file

@ -3,6 +3,7 @@ from uuid import uuid4
from datetime import datetime
from typing import Any, Dict, List
from warchron.model.war_event import WarEvent, InfluenceGained, InfluenceSpent
from warchron.model.exception import ForbiddenOperation
from warchron.model.war_participant import WarParticipant
from warchron.model.objective import Objective
@ -25,6 +26,7 @@ class War:
self.participants: Dict[str, WarParticipant] = {}
self.objectives: Dict[str, Objective] = {}
self.campaigns: List[Campaign] = []
self.events: List[WarEvent] = []
self.is_over: bool = False
def set_id(self, new_id: str) -> None:
@ -53,6 +55,9 @@ class War:
def set_influence_token(self, new_state: bool) -> None:
if self.is_over:
raise ForbiddenOperation("Can't set influence token of a closed war.")
# TODO raise RequiresConfirmation
# * disable: cleanup if any token has already been gained/spent
# * enable: retrigger battle_outcomes and resolve tie again if any draw
self.influence_token = new_state
def set_state(self, new_state: bool) -> None:
@ -69,6 +74,7 @@ class War:
"participants": [part.toDict() for part in self.participants.values()],
"objectives": [obj.toDict() for obj in self.objectives.values()],
"campaigns": [camp.toDict() for camp in self.campaigns],
"events": [ev.toDict() for ev in self.events],
"is_over": self.is_over,
}
@ -87,6 +93,8 @@ class War:
war.objectives[obj.id] = obj
for camp_data in data.get("campaigns", []):
war.campaigns.append(Campaign.fromDict(camp_data))
for ev_data in data.get("events", []):
war.events.append(WarEvent.fromDict(ev_data))
war.set_state(data.get("is_over", False))
return war
@ -439,3 +447,18 @@ class War:
camp = self.get_campaign_by_round(round_id)
if camp is not None:
camp.remove_battle(round_id, sector_id)
# Event methods
def get_influence_tokens(self, participant_id: str) -> int:
gained = sum(
e.amount
for e in self.events
if isinstance(e, InfluenceGained) and e.participant_id == participant_id
)
spent = sum(
e.amount
for e in self.events
if isinstance(e, InfluenceSpent) and e.participant_id == participant_id
)
return gained - spent

View file

@ -1,30 +1,143 @@
from __future__ import annotations
from typing import Dict, Any, TypeVar, Type, cast
from datetime import datetime
from uuid import uuid4
T = TypeVar("T", bound="WarEvent")
EVENT_REGISTRY: Dict[str, Type["WarEvent"]] = {}
def register_event(cls: Type[T]) -> Type[T]:
EVENT_REGISTRY[cls.TYPE] = cls
return cls
class WarEvent:
def __init__(self, participant_id: str):
TYPE = "WarEvent"
def __init__(self, participant_id: str | None = None):
self.id: str = str(uuid4())
self.participant_id: str = participant_id
self.participant_id: str | None = participant_id
self.timestamp: datetime = datetime.now()
def set_id(self, new_id: str) -> None:
self.id = new_id
def set_participant(self, new_war_part_id: str | None) -> None:
self.participant_id = new_war_part_id
def set_timestamp(self, new_timestamp: datetime) -> None:
self.timestamp = new_timestamp
def toDict(self) -> Dict[str, Any]:
return {
"type": self.TYPE,
"id": self.id,
"participant_id": self.participant_id,
"timestamp": self.timestamp.isoformat(),
}
@classmethod
def _base_fromDict(cls: Type[T], ev: T, data: Dict[str, Any]) -> T:
ev.id = data["id"]
ev.participant_id = data["participant_id"]
ev.timestamp = datetime.fromisoformat(data["timestamp"])
return ev
@staticmethod
def fromDict(data: Dict[str, Any]) -> "WarEvent":
ev_type = data["type"]
cls = cast(Type[WarEvent], EVENT_REGISTRY.get(ev_type))
if cls is None:
raise ValueError(f"Unknown event type: {ev_type}")
return cls.fromDict(data)
@register_event
class TieResolved(WarEvent):
def __init__(self, participant_id: str, context_type: str, context_id: str):
TYPE = "TieResolved"
def __init__(self, participant_id: str | None, context_type: str, context_id: str):
super().__init__(participant_id)
self.participant_id: str | None = (
participant_id # winner or None (confirmed tie)
)
self.context_type = context_type # battle, round, campaign, war
self.context_id = context_id
def toDict(self) -> Dict[str, Any]:
d = super().toDict()
d.update(
{
"context_type": self.context_type,
"context_id": self.context_id,
}
)
return d
@classmethod
def fromDict(cls, data: Dict[str, Any]) -> TieResolved:
ev = cls(
data["participant_id"],
data["context_type"],
data["context_id"],
)
return cls._base_fromDict(ev, data)
@register_event
class InfluenceGained(WarEvent):
TYPE = "InfluenceGained"
def __init__(self, participant_id: str, amount: int, source: str):
super().__init__(participant_id)
self.amount = amount
self.source = source # "battle", "tie_resolution", etc.
def toDict(self) -> Dict[str, Any]:
d = super().toDict()
d.update(
{
"amount": self.amount,
"source": self.source,
}
)
return d
@classmethod
def fromDict(cls, data: Dict[str, Any]) -> InfluenceGained:
ev = cls(
data["participant_id"],
data["amount"],
data["source"],
)
return cls._base_fromDict(ev, data)
@register_event
class InfluenceSpent(WarEvent):
def __init__(self, participant_id: str, amount: int, context: str):
TYPE = "InfluenceSpent"
def __init__(self, participant_id: str, amount: int, context_type: str):
super().__init__(participant_id)
self.amount = amount
self.context = context # "battle_tie", "campaign_tie", etc.
self.context_type = context_type # "battle_tie", "campaign_tie", etc.
def toDict(self) -> Dict[str, Any]:
d = super().toDict()
d.update(
{
"amount": self.amount,
"context_type": self.context_type,
}
)
return d
@classmethod
def fromDict(cls, data: Dict[str, Any]) -> InfluenceSpent:
ev = cls(
data["participant_id"],
data["amount"],
data["context_type"],
)
return cls._base_fromDict(ev, data)

View file

@ -1,12 +1,6 @@
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:
@ -14,13 +8,12 @@ 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
def set_player(self, new_player: str) -> None:
self.player_id = new_player
def set_player(self, new_player_id: str) -> None:
self.player_id = new_player_id
def set_faction(self, new_faction: str) -> None:
self.faction = new_faction
@ -40,16 +33,3 @@ 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)