fix re-enable token on closed camapign + refacto war_event attributes

This commit is contained in:
Maxime Réaux 2026-02-25 16:54:21 +01:00
parent 5c124f9229
commit 6efd22527a
8 changed files with 108 additions and 57 deletions

View file

@ -1,4 +1,4 @@
from typing import List, Dict, TYPE_CHECKING from typing import List, Dict, Tuple, TYPE_CHECKING
from PyQt6.QtWidgets import QMessageBox, QDialog from PyQt6.QtWidgets import QMessageBox, QDialog
from PyQt6.QtGui import QIcon from PyQt6.QtGui import QIcon
@ -54,9 +54,8 @@ class CampaignController:
icon_map = {} icon_map = {}
for rank, group, token_map in ranking: for rank, group, token_map in ranking:
base_icon = RANK_TO_ICON.get(rank, IconName.VPNTH) base_icon = RANK_TO_ICON.get(rank, IconName.VPNTH)
tie_id = f"{campaign.id}:score:{scores[group[0]].victory_points}"
tie_resolved = TieResolver.is_tie_resolved( tie_resolved = TieResolver.is_tie_resolved(
war, ContextType.CAMPAIGN, tie_id war, ContextType.CAMPAIGN, campaign.id, scores[group[0]].victory_points
) )
for pid in group: for pid in group:
spent = token_map.get(pid, 0) spent = token_map.get(pid, 0)
@ -189,7 +188,7 @@ class CampaignController:
def resolve_ties( def resolve_ties(
self, war: War, contexts: List[TieContext] self, war: War, contexts: List[TieContext]
) -> Dict[str, Dict[str, bool]]: ) -> Dict[Tuple[ContextType, str, int | None], Dict[str, bool]]:
bids_map = {} bids_map = {}
for ctx in contexts: for ctx in contexts:
active = TieResolver.get_active_participants( active = TieResolver.get_active_participants(
@ -208,11 +207,17 @@ class CampaignController:
context_id=ctx.context_id, context_id=ctx.context_id,
) )
if not dialog.exec(): if not dialog.exec():
TieResolver.cancel_tie_break(war, ContextType.CAMPAIGN, ctx.context_id) TieResolver.cancel_tie_break(
war, ContextType.CAMPAIGN, ctx.context_id, ctx.score_value
)
raise ForbiddenOperation("Tie resolution cancelled") raise ForbiddenOperation("Tie resolution cancelled")
bids_map[ctx.context_id] = dialog.get_bids() bids_map[(ctx.context_type, ctx.context_id, ctx.score_value)] = (
dialog.get_bids()
)
return bids_map return bids_map
# Campaign participant methods
def create_campaign_participant(self) -> CampaignParticipant | None: def create_campaign_participant(self) -> CampaignParticipant | None:
if not self.app.navigation.selected_campaign_id: if not self.app.navigation.selected_campaign_id:
return None return None

View file

@ -24,11 +24,9 @@ class RoundClosureWorkflow(ClosureWorkflow):
while ties: while ties:
bids_map = self.app.rounds.resolve_ties(war, ties) bids_map = self.app.rounds.resolve_ties(war, ties)
for tie in ties: for tie in ties:
bids = bids_map[tie.context_id] bids = bids_map[(tie.context_type, tie.context_id, tie.score_value)]
TieResolver.apply_bids(war, tie.context_type, tie.context_id, bids) TieResolver.apply_bids(war, tie.context_type, tie.context_id, bids)
TieResolver.resolve_tie_state( TieResolver.resolve_tie_state(war, tie, bids)
war, tie.context_type, tie.context_id, tie.participants, bids
)
ties = TieResolver.find_battle_ties(war, round.id) ties = TieResolver.find_battle_ties(war, round.id)
for battle in round.battles.values(): for battle in round.battles.values():
ClosureService.apply_battle_outcomes(war, campaign, battle) ClosureService.apply_battle_outcomes(war, campaign, battle)
@ -43,11 +41,9 @@ class CampaignClosureWorkflow(ClosureWorkflow):
while ties: while ties:
bids_map = self.app.campaigns.resolve_ties(war, ties) bids_map = self.app.campaigns.resolve_ties(war, ties)
for tie in ties: for tie in ties:
bids = bids_map[tie.context_id] bids = bids_map[(tie.context_type, tie.context_id, tie.score_value)]
TieResolver.apply_bids(war, tie.context_type, tie.context_id, bids) TieResolver.apply_bids(war, tie.context_type, tie.context_id, bids)
TieResolver.resolve_tie_state( TieResolver.resolve_tie_state(war, tie, bids)
war, tie.context_type, tie.context_id, tie.participants, bids
)
ties = TieResolver.find_campaign_ties(war, campaign.id) ties = TieResolver.find_campaign_ties(war, campaign.id)
ClosureService.finalize_campaign(campaign) ClosureService.finalize_campaign(campaign)
@ -60,10 +56,8 @@ class WarClosureWorkflow(ClosureWorkflow):
while ties: while ties:
bids_map = self.app.wars.resolve_ties(war, ties) bids_map = self.app.wars.resolve_ties(war, ties)
for tie in ties: for tie in ties:
bids = bids_map[tie.context_id] bids = bids_map[(tie.context_type, tie.context_id, tie.score_value)]
TieResolver.apply_bids(war, tie.context_type, tie.context_id, bids) TieResolver.apply_bids(war, tie.context_type, tie.context_id, bids)
TieResolver.resolve_tie_state( TieResolver.resolve_tie_state(war, tie, bids)
war, tie.context_type, tie.context_id, tie.participants, bids
)
ties = TieResolver.find_war_ties(war) ties = TieResolver.find_war_ties(war)
ClosureService.finalize_war(war) ClosureService.finalize_war(war)

View file

@ -1,4 +1,4 @@
from typing import List, Dict, TYPE_CHECKING from typing import List, Dict, Tuple, TYPE_CHECKING
from PyQt6.QtWidgets import QDialog from PyQt6.QtWidgets import QDialog
from PyQt6.QtWidgets import QMessageBox from PyQt6.QtWidgets import QMessageBox
@ -179,7 +179,7 @@ class RoundController:
def resolve_ties( def resolve_ties(
self, war: War, contexts: List[TieContext] self, war: War, contexts: List[TieContext]
) -> Dict[str, Dict[str, bool]]: ) -> Dict[Tuple[ContextType, str, int | None], Dict[str, bool]]:
bids_map = {} bids_map = {}
for ctx in contexts: for ctx in contexts:
players = [ players = [
@ -198,9 +198,13 @@ class RoundController:
context_id=ctx.context_id, context_id=ctx.context_id,
) )
if not dialog.exec(): if not dialog.exec():
TieResolver.cancel_tie_break(war, ContextType.BATTLE, ctx.context_id) TieResolver.cancel_tie_break(
war, ContextType.BATTLE, ctx.context_id, ctx.score_value
)
raise ForbiddenOperation("Tie resolution cancelled") raise ForbiddenOperation("Tie resolution cancelled")
bids_map[ctx.context_id] = dialog.get_bids() bids_map[(ctx.context_type, ctx.context_id, ctx.score_value)] = (
dialog.get_bids()
)
return bids_map return bids_map
# Choice methods # Choice methods

View file

@ -1,4 +1,4 @@
from typing import List, TYPE_CHECKING, Dict from typing import List, Tuple, TYPE_CHECKING, Dict
from PyQt6.QtWidgets import QMessageBox, QDialog from PyQt6.QtWidgets import QMessageBox, QDialog
from PyQt6.QtGui import QIcon from PyQt6.QtGui import QIcon
@ -53,8 +53,9 @@ class WarController:
icon_map = {} icon_map = {}
for rank, group, token_map in ranking: for rank, group, token_map in ranking:
base_icon = RANK_TO_ICON.get(rank, IconName.VPNTH) base_icon = RANK_TO_ICON.get(rank, IconName.VPNTH)
tie_id = f"{war.id}:score:{scores[group[0]].victory_points}" tie_resolved = TieResolver.is_tie_resolved(
tie_resolved = TieResolver.is_tie_resolved(war, ContextType.WAR, tie_id) war, ContextType.WAR, war.id, scores[group[0]].victory_points
)
for pid in group: for pid in group:
spent = token_map.get(pid, 0) spent = token_map.get(pid, 0)
if not tie_resolved and spent == 0: if not tie_resolved and spent == 0:
@ -169,11 +170,14 @@ class WarController:
# TODO fix ignored campaign tie-breaks # TODO fix ignored campaign tie-breaks
def resolve_ties( def resolve_ties(
self, war: War, contexts: List[TieContext] self, war: War, contexts: List[TieContext]
) -> Dict[str, Dict[str, bool]]: ) -> Dict[Tuple[ContextType, str, int | None], Dict[str, bool]]:
bids_map = {} bids_map = {}
for ctx in contexts: for ctx in contexts:
active = TieResolver.get_active_participants( active = TieResolver.get_active_participants(
war, ctx.context_type, ctx.context_id, ctx.participants war,
ctx.context_type,
ctx.context_id,
ctx.participants,
) )
players = [ players = [
ParticipantOption(id=pid, name=self.app.model.get_participant_name(pid)) ParticipantOption(id=pid, name=self.app.model.get_participant_name(pid))
@ -188,9 +192,13 @@ class WarController:
context_id=ctx.context_id, context_id=ctx.context_id,
) )
if not dialog.exec(): if not dialog.exec():
TieResolver.cancel_tie_break(war, ContextType.WAR, ctx.context_id) TieResolver.cancel_tie_break(
war, ContextType.WAR, ctx.context_id, ctx.score_value
)
raise ForbiddenOperation("Tie resolution cancelled") raise ForbiddenOperation("Tie resolution cancelled")
bids_map[ctx.context_id] = dialog.get_bids() bids_map[(ctx.context_type, ctx.context_id, ctx.score_value)] = (
dialog.get_bids()
)
return bids_map return bids_map
def set_major_value(self, value: int) -> None: def set_major_value(self, value: int) -> None:

View file

@ -27,8 +27,7 @@ class ClosureService:
from warchron.model.result_checker import ResultChecker from warchron.model.result_checker import ResultChecker
already_granted = any( already_granted = any(
isinstance(e, InfluenceGained) isinstance(e, InfluenceGained) and e.context_id == battle.sector_id
and e.context_id == f"battle:{battle.sector_id}"
for e in war.events for e in war.events
) )
if already_granted: if already_granted:
@ -51,7 +50,7 @@ class ClosureService:
participant_id=effective_winner, participant_id=effective_winner,
amount=1, amount=1,
context_type=ContextType.BATTLE, context_type=ContextType.BATTLE,
context_id=f"battle:{battle.sector_id}", context_id=battle.sector_id,
) )
) )

View file

@ -45,10 +45,9 @@ class ResultChecker:
current_rank = 1 current_rank = 1
for vp in sorted_vps: for vp in sorted_vps:
participants = vp_buckets[vp] participants = vp_buckets[vp]
tie_id = f"{context_id}:score:{vp}"
# no tie # no tie
if len(participants) == 1 or not TieResolver.is_tie_resolved( if len(participants) == 1 or not TieResolver.is_tie_resolved(
war, context_type, tie_id war, context_type, context_id, vp
): ):
ranking.append( ranking.append(
(current_rank, participants, {pid: 0 for pid in participants}) (current_rank, participants, {pid: 0 for pid in participants})
@ -59,11 +58,11 @@ class ResultChecker:
groups = TieResolver.rank_by_tokens( groups = TieResolver.rank_by_tokens(
war, war,
context_type, context_type,
tie_id, context_id,
participants, participants,
) )
tokens_spent = TieResolver.tokens_spent_map( tokens_spent = TieResolver.tokens_spent_map(
war, context_type, tie_id, participants war, context_type, context_id, participants
) )
for group in groups: for group in groups:
group_tokens = {pid: tokens_spent[pid] for pid in group} group_tokens = {pid: tokens_spent[pid] for pid in group}

View file

@ -14,6 +14,7 @@ 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
score_value: int | None = None
class TieResolver: class TieResolver:
@ -47,6 +48,7 @@ class TieResolver:
context_type=ContextType.BATTLE, context_type=ContextType.BATTLE,
context_id=battle.sector_id, context_id=battle.sector_id,
participants=[p1, p2], participants=[p1, p2],
score_value=None,
) )
) )
return ties return ties
@ -63,19 +65,23 @@ class TieResolver:
for score_value, participants in buckets.items(): for score_value, participants in buckets.items():
if len(participants) <= 1: if len(participants) <= 1:
continue continue
tie_id = f"{campaign_id}:score:{score_value}" if TieResolver.is_tie_resolved(
if TieResolver.is_tie_resolved(war, ContextType.CAMPAIGN, tie_id): war, ContextType.CAMPAIGN, campaign_id, score_value
):
continue continue
if not TieResolver.can_tie_be_resolved( if not TieResolver.can_tie_be_resolved(
war, ContextType.CAMPAIGN, tie_id, participants war, ContextType.CAMPAIGN, campaign_id, participants
): ):
war.events.append(TieResolved(None, ContextType.CAMPAIGN, tie_id)) war.events.append(
TieResolved(None, ContextType.CAMPAIGN, campaign_id, score_value)
)
continue continue
ties.append( ties.append(
TieContext( TieContext(
context_type=ContextType.CAMPAIGN, context_type=ContextType.CAMPAIGN,
context_id=tie_id, context_id=campaign_id,
participants=participants, participants=participants,
score_value=score_value,
) )
) )
return ties return ties
@ -92,19 +98,21 @@ class TieResolver:
for score_value, participants in buckets.items(): for score_value, participants in buckets.items():
if len(participants) <= 1: if len(participants) <= 1:
continue continue
tie_id = f"{war.id}:score:{score_value}" if TieResolver.is_tie_resolved(war, ContextType.WAR, war.id, score_value):
if TieResolver.is_tie_resolved(war, ContextType.WAR, tie_id):
continue continue
if not TieResolver.can_tie_be_resolved( if not TieResolver.can_tie_be_resolved(
war, ContextType.WAR, tie_id, participants war, ContextType.WAR, war.id, participants
): ):
war.events.append(TieResolved(None, ContextType.WAR, tie_id)) war.events.append(
TieResolved(None, ContextType.WAR, war.id, score_value)
)
continue continue
ties.append( ties.append(
TieContext( TieContext(
context_type=ContextType.WAR, context_type=ContextType.WAR,
context_id=tie_id, context_id=war.id,
participants=participants, participants=participants,
score_value=score_value,
) )
) )
return ties return ties
@ -135,6 +143,7 @@ class TieResolver:
war: War, war: War,
context_type: ContextType, context_type: ContextType,
context_id: str, context_id: str,
score_value: int | None = None,
) -> None: ) -> None:
war.events = [ war.events = [
ev ev
@ -149,6 +158,7 @@ class TieResolver:
isinstance(ev, TieResolved) isinstance(ev, TieResolved)
and ev.context_type == context_type and ev.context_type == context_type
and ev.context_id == context_id and ev.context_id == context_id
and ev.score_value == score_value
) )
) )
] ]
@ -210,26 +220,37 @@ class TieResolver:
@staticmethod @staticmethod
def resolve_tie_state( def resolve_tie_state(
war: War, war: War,
context_type: ContextType, ctx: TieContext,
context_id: str,
participants: List[str],
bids: dict[str, bool] | None = None, bids: dict[str, bool] | None = None,
) -> None: ) -> None:
active = TieResolver.get_active_participants( active = TieResolver.get_active_participants(
war, context_type, context_id, participants war,
ctx.context_type,
ctx.context_id,
ctx.participants,
) )
# confirmed draw if non had bid # confirmed draw if non had bid
if not active: if not active:
war.events.append(TieResolved(None, context_type, context_id)) war.events.append(
TieResolved(None, ctx.context_type, ctx.context_id, ctx.score_value)
)
return return
# confirmed draw if current bids are 0 # confirmed draw if current bids are 0
if bids is not None and not any(bids.values()): if bids is not None and not any(bids.values()):
war.events.append(TieResolved(None, context_type, context_id)) war.events.append(
TieResolved(None, ctx.context_type, ctx.context_id, ctx.score_value)
)
return return
# else rank_by_tokens # else rank_by_tokens
groups = TieResolver.rank_by_tokens(war, context_type, context_id, participants) groups = TieResolver.rank_by_tokens(
war, ctx.context_type, ctx.context_id, ctx.participants
)
if len(groups[0]) == 1: if len(groups[0]) == 1:
war.events.append(TieResolved(groups[0][0], context_type, context_id)) war.events.append(
TieResolved(
groups[0][0], ctx.context_type, ctx.context_id, ctx.score_value
)
)
return return
# if tie persists, do nothing, workflow will call again # if tie persists, do nothing, workflow will call again
@ -247,21 +268,29 @@ class TieResolver:
war: War, war: War,
context_type: ContextType, context_type: ContextType,
context_id: str, context_id: str,
score_value: int | None = None,
) -> bool: ) -> bool:
for ev in reversed(war.events): for ev in reversed(war.events):
if ( if (
isinstance(ev, TieResolved) isinstance(ev, TieResolved)
and ev.context_type == context_type and ev.context_type == context_type
and ev.context_id == context_id and ev.context_id == context_id
and ev.score_value == score_value
): ):
return ev.participant_id is not None return ev.participant_id is not None
return False return False
@staticmethod @staticmethod
def is_tie_resolved(war: War, context_type: ContextType, context_id: str) -> bool: def is_tie_resolved(
war: War,
context_type: ContextType,
context_id: str,
score_value: int | None = None,
) -> bool:
return any( return any(
isinstance(ev, TieResolved) isinstance(ev, TieResolved)
and ev.context_type == context_type and ev.context_type == context_type
and ev.context_id == context_id and ev.context_id == context_id
and ev.score_value == score_value
for ev in war.events for ev in war.events
) )

View file

@ -63,19 +63,32 @@ class WarEvent:
class TieResolved(WarEvent): class TieResolved(WarEvent):
TYPE = "TieResolved" TYPE = "TieResolved"
def __init__(self, participant_id: str | None, context_type: str, context_id: str): def __init__(
self,
participant_id: str | None,
context_type: str,
context_id: str,
score_value: int | None = None,
):
super().__init__(participant_id, context_type, context_id) super().__init__(participant_id, context_type, context_id)
self.score_value = score_value
def toDict(self) -> Dict[str, Any]: def toDict(self) -> Dict[str, Any]:
d = super().toDict() d = super().toDict()
d.update(
{
"score_value": self.score_value or None,
}
)
return d return d
@classmethod @classmethod
def fromDict(cls, data: Dict[str, Any]) -> TieResolved: def fromDict(cls, data: Dict[str, Any]) -> TieResolved:
ev = cls( ev = cls(
data["participant_id"], data["participant_id"] or None,
data["context_type"], data["context_type"],
data["context_id"], data["context_id"],
data["score_value"] or None,
) )
return cls._base_fromDict(ev, data) return cls._base_fromDict(ev, data)