from abc import ABC, abstractmethod
from dataclasses import dataclass
from yasuki_core.engine.players import PlayerId
[docs]
@dataclass(frozen=True, slots=True)
class DecisionResponse:
"""A seat's answer to the pending :class:`DecisionRequest`.
Carries the chosen identifiers — card ids, gold-source ids, or an ordering — interpreted by
the request being answered. One uniform shape so the decision log, the save format, and the
netcode all serialize answers the same way.
Attributes
----------
choices : tuple of str
The chosen identifiers, in the order the seat picked them. Default empty.
boosted : tuple of str
The subset of ``choices`` whose bow-time production boost the seat took — a boostable
producer raised to its higher yield as it bows, then destroyed. Only meaningful answering a
:class:`ChoosePayment`. Default empty.
"""
choices: tuple[str, ...] = ()
boosted: tuple[str, ...] = ()
[docs]
@dataclass(frozen=True, slots=True)
class DecisionRequest(ABC):
"""A question the engine pauses to put to one seat.
The engine runs until it needs input, records a concrete request on ``GameState.pending``, and
returns; the seat answers with a :class:`DecisionResponse` and the engine resumes. Concrete
requests form a closed union that grows with the rules vocabulary.
Attributes
----------
seat : PlayerId
The seat that must answer.
candidates : tuple of str
The ids the seat may choose among — the request's legal options. A client renders these as
the selectable cards, and a well-formed answer draws only from them.
"""
seat: PlayerId
candidates: tuple[str, ...]
[docs]
@abstractmethod
def accepts(self, response: DecisionResponse) -> bool:
"""Return whether ``response`` is a structurally well-formed answer to this request — the
right shape, drawn from :attr:`candidates`. A well-formed answer may still be illegal
against the game state; the rules layer makes that check separately."""
@property
def cancellable(self) -> bool:
"""Whether the seat may back out of this decision, undoing the action that raised it. False
for a forced decision the seat must answer."""
return False
[docs]
@dataclass(frozen=True, slots=True)
class ChoosePayment(DecisionRequest):
"""The seat must cover a gold cost, bowing gold producers to make up what its pool lacks. The
candidates are the seat's unbowed producers; choosing some bows them, and their production plus
the pool must reach the cost. Excess stays in the pool.
The request snapshots everything :meth:`accepts` needs, so validity is structural: the cost, the
pool on hand when the cost arose, and each producer's yield.
Attributes
----------
amount : int
The gold cost to cover.
available : int
The gold already in the seat's pool when the cost arose.
produced : tuple of (str, int)
Each candidate producer paired with the gold it yields when bowed.
label : str
What the payment is for (e.g. the recruited card's name), shown in the prompt.
boostable : tuple of (str, int)
Each producer that may raise its yield as it bows, paired with the extra gold its boost
adds. The seat opts in per producer via the answer's ``boosted``; a boosted producer counts
its extra toward the cost and is destroyed after bowing.
"""
amount: int
available: int
produced: tuple[tuple[str, int], ...]
label: str
boostable: tuple[tuple[str, int], ...] = ()
[docs]
def accepts(self, response: DecisionResponse) -> bool:
chosen = response.choices
distinct = set(chosen)
if len(distinct) != len(chosen) or not distinct <= set(self.candidates):
return False
boost = dict(self.boostable)
boosted = set(response.boosted)
if not boosted <= (distinct & boost.keys()):
return False
yields = dict(self.produced)
covered = sum(yields[c] + (boost[c] if c in boosted else 0) for c in distinct)
return self.available + covered >= self.amount
@property
def cancellable(self) -> bool:
"""A Recruit's payment can be backed out of: nothing is committed until it is answered."""
return True
[docs]
@dataclass(frozen=True, slots=True)
class DiscardToHandSize(DecisionRequest):
"""The seat must discard ``count`` cards from hand to reach the maximum hand size, taken at the
end of its turn. The candidates are the seat's current hand.
Attributes
----------
count : int
How many cards the seat must discard.
"""
count: int
[docs]
def accepts(self, response: DecisionResponse) -> bool:
chosen = set(response.choices)
return (
len(response.choices) == self.count
and len(chosen) == self.count
and chosen <= set(self.candidates)
)
def _chooses_exactly_one(request: "DecisionRequest", response: DecisionResponse) -> bool:
return len(response.choices) == 1 and response.choices[0] in request.candidates
[docs]
@dataclass(frozen=True, slots=True)
class BanishForLegacy(DecisionRequest):
"""The seat must banish one card from hand to pay for the Legacy ability. The candidates are the
seat's hand; the chosen card is removed from the game. Not cancellable — announcing Legacy
commits to the cost."""
[docs]
def accepts(self, response: DecisionResponse) -> bool:
return _chooses_exactly_one(self, response)
[docs]
@dataclass(frozen=True, slots=True)
class ChooseLegacyCard(DecisionRequest):
"""The seat must choose which Legacy card its search found — the candidates are the Legacy cards
in its dynasty deck and provinces. The chosen card is placed into a province next."""
[docs]
def accepts(self, response: DecisionResponse) -> bool:
return _chooses_exactly_one(self, response)
[docs]
@dataclass(frozen=True, slots=True)
class ChooseInvestAmount(DecisionRequest):
"""The seat must choose how much to Invest while recruiting a variable-Invest holding. The
candidates are the affordable amounts rendered as strings; the chosen amount is added to the
recruit payment and drives the Invest effect. Cancellable — nothing is committed until the
payment that follows.
Attributes
----------
source_card_id : str
The holding being recruited with Invest.
"""
source_card_id: str
[docs]
def accepts(self, response: DecisionResponse) -> bool:
return _chooses_exactly_one(self, response)
@property
def cancellable(self) -> bool:
return True
[docs]
@dataclass(frozen=True, slots=True)
class ChooseAbilityTarget(DecisionRequest):
"""The seat must choose the target of an activated ability it has announced. The candidates are
the cards the ability may legally target — all in play, so a client renders them as board
selections.
Attributes
----------
source_card_id : str
The card whose ability is resolving, whose effects apply to the chosen target.
"""
source_card_id: str
[docs]
def accepts(self, response: DecisionResponse) -> bool:
return _chooses_exactly_one(self, response)
[docs]
@dataclass(frozen=True, slots=True)
class ChooseCards(DecisionRequest):
"""The seat must choose between ``minimum`` and ``maximum`` of the candidate cards — a
variable-count target, as when a triggered effect targets "zero to two" cards. The chosen ids
feed the named resolver, whose effects apply once the choice is made. The candidates are the
cards the effect may legally target, all in play, so a client renders them as board selections.
Attributes
----------
minimum : int
The fewest cards the seat may choose — zero when the effect is optional.
maximum : int
The most cards the seat may choose.
resolver : str
The registered choice resolver that turns the chosen ids into effects.
source_id : str
The card whose effect raised the choice, passed to the resolver.
"""
minimum: int
maximum: int
resolver: str
source_id: str
[docs]
def accepts(self, response: DecisionResponse) -> bool:
chosen = response.choices
distinct = set(chosen)
return (
len(distinct) == len(chosen)
and self.minimum <= len(chosen) <= self.maximum
and distinct <= set(self.candidates)
)
[docs]
@dataclass(frozen=True, slots=True)
class PlaceLegacy(DecisionRequest):
"""The seat must choose which province to place the found Legacy card into, discarding the card
already there. The candidates are the province cards eligible to be displaced.
Attributes
----------
legacy_card_id : str
The Legacy card that will be placed face-up into the chosen province.
"""
legacy_card_id: str
[docs]
def accepts(self, response: DecisionResponse) -> bool:
return _chooses_exactly_one(self, response)