fix ignored campaign NP tie-break when closing war

This commit is contained in:
Maxime Réaux 2026-03-06 15:02:53 +01:00
parent b1bde76319
commit 72f80563f1
16 changed files with 314 additions and 219 deletions

View file

@ -1,8 +1,8 @@
from typing import List, Dict, DefaultDict
from dataclasses import dataclass
from dataclasses import dataclass, field
from collections import defaultdict
from warchron.constants import ContextType
from warchron.constants import ContextType, ScoreKind
from warchron.model.exception import ForbiddenOperation
from warchron.model.war import War
from warchron.model.war_event import InfluenceSpent, TieResolved
@ -13,8 +13,13 @@ from warchron.model.score_service import ScoreService, ParticipantScore
class TieContext:
context_type: ContextType
context_id: str
participants: List[str] # war_participant_ids
participants: List[str] = field(default_factory=list) # war_participant_ids
score_value: int | None = None
score_kind: ScoreKind | None = None
objective_id: str | None = None
def key(self) -> tuple[str, str, int | None]:
return (self.context_type, self.context_id, self.score_value)
class TieResolver:
@ -27,9 +32,11 @@ class TieResolver:
for battle in round.battles.values():
if not battle.is_draw():
continue
if TieResolver.is_tie_resolved(
war, ContextType.BATTLE, battle.sector_id, None
):
context: TieContext = TieContext(
ContextType.BATTLE,
battle.sector_id,
)
if TieResolver.is_tie_resolved(war, context):
continue
if campaign is None:
raise RuntimeError("No campaign for this battle tie")
@ -37,9 +44,7 @@ class TieResolver:
raise RuntimeError("Missing player(s) in this battle context.")
p1 = campaign.participants[battle.player_1_id].war_participant_id
p2 = campaign.participants[battle.player_2_id].war_participant_id
if not TieResolver.can_tie_be_resolved(
war, ContextType.BATTLE, battle.sector_id, [p1, p2]
):
if not TieResolver.can_tie_be_resolved(war, context, [p1, p2]):
war.events.append(
TieResolved(None, ContextType.BATTLE, battle.sector_id)
)
@ -50,6 +55,7 @@ class TieResolver:
context_id=battle.sector_id,
participants=[p1, p2],
score_value=None,
score_kind=None,
)
)
return ties
@ -64,15 +70,23 @@ class TieResolver:
for score_value, participants in buckets.items():
if len(participants) <= 1:
continue
if TieResolver.is_tie_resolved(
war, ContextType.CAMPAIGN, campaign_id, score_value
):
context: TieContext = TieContext(
ContextType.CAMPAIGN,
campaign_id,
[],
score_value,
ScoreKind.VP,
)
if TieResolver.is_tie_resolved(war, context):
continue
if not TieResolver.can_tie_be_resolved(
war, ContextType.CAMPAIGN, campaign_id, participants
):
if not TieResolver.can_tie_be_resolved(war, context, participants):
war.events.append(
TieResolved(None, ContextType.CAMPAIGN, campaign_id, score_value)
TieResolved(
None,
ContextType.CAMPAIGN,
campaign_id,
score_value,
)
)
continue
ties.append(
@ -81,6 +95,7 @@ class TieResolver:
context_id=campaign_id,
participants=participants,
score_value=score_value,
score_kind=ScoreKind.VP,
)
)
return ties
@ -101,38 +116,39 @@ class TieResolver:
np_value = score.narrative_points.get(objective_id, 0)
buckets[np_value].append(pid)
ties: List[TieContext] = []
context_id = f"{campaign_id}:{objective_id}"
context_id = campaign_id
for np_value, participants in buckets.items():
if len(participants) <= 1:
continue
if TieResolver.is_tie_resolved(
war,
ContextType.OBJECTIVE,
context_id,
context: TieContext = TieContext(
ContextType.CAMPAIGN,
campaign_id,
[],
np_value,
):
ScoreKind.NP,
objective_id,
)
if TieResolver.is_tie_resolved(war, context):
continue
if not TieResolver.can_tie_be_resolved(
war,
ContextType.OBJECTIVE,
context_id,
context,
participants,
):
war.events.append(
TieResolved(
None,
ContextType.OBJECTIVE,
context_id,
np_value,
None, ContextType.CAMPAIGN, context_id, np_value, objective_id
)
)
continue
ties.append(
TieContext(
context_type=ContextType.OBJECTIVE,
context_type=ContextType.CAMPAIGN,
context_id=context_id,
participants=participants,
score_value=np_value,
score_kind=ScoreKind.NP,
objective_id=objective_id,
)
)
return ties
@ -146,17 +162,25 @@ class TieResolver:
war,
ContextType.WAR,
war.id,
ScoreKind.VP,
scores,
value_getter=lambda s: s.victory_points,
lambda s: s.victory_points,
)
ties: List[TieContext] = []
for _, group, _ in ranking:
if len(group) <= 1:
continue
score_value = scores[group[0]].victory_points
if TieResolver.is_tie_resolved(war, ContextType.WAR, war.id, score_value):
context: TieContext = TieContext(
ContextType.WAR,
war.id,
[],
score_value,
ScoreKind.VP,
)
if TieResolver.is_tie_resolved(war, context):
continue
if not TieResolver.can_tie_be_resolved(war, ContextType.WAR, war.id, group):
if not TieResolver.can_tie_be_resolved(war, context, group):
war.events.append(
TieResolved(None, ContextType.WAR, war.id, score_value)
)
@ -167,6 +191,7 @@ class TieResolver:
context_id=war.id,
participants=group,
score_value=score_value,
score_kind=ScoreKind.VP,
)
)
return ties
@ -189,45 +214,43 @@ class TieResolver:
ranking = ResultChecker.get_effective_ranking(
war,
ContextType.OBJECTIVE,
f"{war.id}:{objective_id}",
ContextType.WAR,
war.id,
ScoreKind.NP,
scores,
value_getter=value_getter,
value_getter,
objective_id,
)
ties: List[TieContext] = []
for _, group, _ in ranking:
if len(group) <= 1:
continue
np_value = value_getter(scores[group[0]])
context_id = f"{war.id}:{objective_id}"
context: TieContext = TieContext(
ContextType.WAR, war.id, [], np_value, ScoreKind.NP, objective_id
)
if TieResolver.is_tie_resolved(
war,
ContextType.OBJECTIVE,
context_id,
np_value,
context,
):
continue
if not TieResolver.can_tie_be_resolved(
war,
ContextType.OBJECTIVE,
context_id,
context,
group,
):
war.events.append(
TieResolved(
None,
ContextType.OBJECTIVE,
context_id,
np_value,
)
TieResolved(None, ContextType.WAR, war.id, np_value, objective_id)
)
continue
ties.append(
TieContext(
context_type=ContextType.OBJECTIVE,
context_id=context_id,
context_type=ContextType.WAR,
context_id=war.id,
participants=group,
score_value=np_value,
score_kind=ScoreKind.NP,
objective_id=objective_id,
)
)
return ties
@ -235,8 +258,7 @@ class TieResolver:
@staticmethod
def apply_bids(
war: War,
context_type: ContextType,
context_id: str,
context: TieContext,
bids: Dict[str, bool], # war_participant_id -> spend?
) -> None:
for war_part_id, spend in bids.items():
@ -248,8 +270,9 @@ class TieResolver:
InfluenceSpent(
participant_id=war_part_id,
amount=1,
context_type=context_type,
context_id=context_id,
context_type=context.context_type,
context_id=context.context_id,
objective_id=context.objective_id,
)
)
@ -257,9 +280,7 @@ class TieResolver:
@staticmethod
def cancel_tie_break(
war: War,
context_type: ContextType,
context_id: str,
score_value: int | None = None,
context: TieContext,
) -> None:
war.events = [
ev
@ -267,14 +288,15 @@ class TieResolver:
if not (
(
isinstance(ev, InfluenceSpent)
and ev.context_type == context_type
and ev.context_id == context_id
and ev.context_type == context.context_type
and ev.context_id == context.context_id
)
or (
isinstance(ev, TieResolved)
and ev.context_type == context_type
and ev.context_id == context_id
and ev.score_value == score_value
and ev.context_type == context.context_type
and ev.context_id == context.context_id
and ev.score_value == context.score_value
and ev.objective_id == context.objective_id
)
)
]
@ -282,16 +304,16 @@ class TieResolver:
@staticmethod
def rank_by_tokens(
war: War,
context_type: ContextType,
context_id: str,
context: TieContext,
participants: List[str],
) -> List[List[str]]:
spent = {pid: 0 for pid in participants}
for ev in war.events:
if (
isinstance(ev, InfluenceSpent)
and ev.context_type == context_type
and ev.context_id == context_id
and ev.context_type == context.context_type
and ev.context_id == context.context_id
and ev.objective_id == context.objective_id
and ev.participant_id in spent
):
spent[ev.participant_id] += ev.amount
@ -308,16 +330,16 @@ class TieResolver:
@staticmethod
def tokens_spent_map(
war: War,
context_type: ContextType,
context_id: str,
context: TieContext,
participants: List[str],
) -> Dict[str, int]:
spent = {pid: 0 for pid in participants}
for ev in war.events:
if (
isinstance(ev, InfluenceSpent)
and ev.context_type == context_type
and ev.context_id == context_id
and ev.context_type == context.context_type
and ev.context_id == context.context_id
and ev.objective_id == context.objective_id
and ev.participant_id in spent
):
spent[ev.participant_id] += ev.amount
@ -326,45 +348,57 @@ class TieResolver:
@staticmethod
def get_active_participants(
war: War,
context_type: ContextType,
context_id: str,
context: TieContext,
participants: List[str],
) -> List[str]:
groups = TieResolver.rank_by_tokens(war, context_type, context_id, participants)
groups = TieResolver.rank_by_tokens(war, context, participants)
return groups[0]
@staticmethod
def resolve_tie_state(
war: War,
ctx: TieContext,
context: TieContext,
bids: dict[str, bool] | None = None,
) -> None:
active = TieResolver.get_active_participants(
war,
ctx.context_type,
ctx.context_id,
ctx.participants,
context,
context.participants,
)
# confirmed draw if non had bid
if not active:
war.events.append(
TieResolved(None, ctx.context_type, ctx.context_id, ctx.score_value)
TieResolved(
None,
context.context_type,
context.context_id,
context.score_value,
context.objective_id,
)
)
return
# confirmed draw if current bids are 0
if bids is not None and not any(bids.values()):
war.events.append(
TieResolved(None, ctx.context_type, ctx.context_id, ctx.score_value)
TieResolved(
None,
context.context_type,
context.context_id,
context.score_value,
context.objective_id,
)
)
return
# else rank_by_tokens
groups = TieResolver.rank_by_tokens(
war, ctx.context_type, ctx.context_id, ctx.participants
)
groups = TieResolver.rank_by_tokens(war, context, context.participants)
if len(groups[0]) == 1:
war.events.append(
TieResolved(
groups[0][0], ctx.context_type, ctx.context_id, ctx.score_value
groups[0][0],
context.context_type,
context.context_id,
context.score_value,
context.objective_id,
)
)
return
@ -372,41 +406,34 @@ class TieResolver:
@staticmethod
def can_tie_be_resolved(
war: War, context_type: ContextType, context_id: str, participants: List[str]
war: War, context: TieContext, participants: List[str]
) -> bool:
active = TieResolver.get_active_participants(
war, context_type, context_id, participants
)
active = TieResolver.get_active_participants(war, context, participants)
return any(war.get_influence_tokens(pid) > 0 for pid in active)
@staticmethod
def was_tie_broken_by_tokens(
war: War,
context_type: ContextType,
context_id: str,
score_value: int | None = None,
context: TieContext,
) -> bool:
for ev in reversed(war.events):
if (
isinstance(ev, TieResolved)
and ev.context_type == context_type
and ev.context_id == context_id
and ev.score_value == score_value
and ev.context_type == context.context_type
and ev.context_id == context.context_id
and ev.score_value == context.score_value
and ev.objective_id == context.objective_id
):
return ev.participant_id is not None
return False
@staticmethod
def is_tie_resolved(
war: War,
context_type: ContextType,
context_id: str,
score_value: int | None = None,
) -> bool:
def is_tie_resolved(war: War, context: TieContext) -> bool:
return any(
isinstance(ev, TieResolved)
and ev.context_type == context_type
and ev.context_id == context_id
and ev.score_value == score_value
and ev.context_type == context.context_type
and ev.context_id == context.context_id
and ev.score_value == context.score_value
and ev.objective_id == context.objective_id
for ev in war.events
)