Source code for yasuki_core.engine.rules.effects

from collections.abc import Callable, Iterator
from dataclasses import dataclass

from yasuki_core.engine.players import PlayerId
from yasuki_core.engine.rules.modifiers import Duration, Modifier, Stat
from yasuki_core.engine.rules.state import GameState
from yasuki_core.game_pieces.cards import L5RCard
from yasuki_core.game_pieces.counters import ALL_COUNTERS, SINCERITY
from yasuki_core.game_pieces.dynasty import DynastyHolding
from yasuki_core.game_pieces.pregame import StrongholdCard


[docs] @dataclass(frozen=True, slots=True) class PlayerState: """A read-only view of one seat — the vocabulary a card effect reasons over: the seat's stronghold, the cards it controls in play, and its current gold and honor.""" seat: PlayerId stronghold: StrongholdCard | None in_play: tuple[L5RCard, ...] gold: int honor: int went_second: bool @property def holdings(self) -> tuple[DynastyHolding, ...]: """The Holdings the seat controls in play.""" return tuple(card for card in self.in_play if isinstance(card, DynastyHolding))
[docs] def controls(self, keyword: str, *, other_than: L5RCard | None = None) -> bool: """Whether the seat controls an in-play card carrying ``keyword``, optionally excluding one card so an "another"/"other" clause can skip the card asking. Parameters ---------- keyword : str The keyword to look for among controlled cards. other_than : L5RCard, optional A card to exclude from the search (matched by identity). Default None. """ return any(keyword in card.keywords and card is not other_than for card in self.in_play)
[docs] def player_state(game: GameState, seat: PlayerId) -> PlayerState: """Build the read-only :class:`PlayerState` view for ``seat`` from the live game.""" in_play = tuple(card for card in game.table.battlefield.cards if card.owner is seat) stronghold = next((card for card in in_play if isinstance(card, StrongholdCard)), None) return PlayerState( seat=seat, stronghold=stronghold, in_play=in_play, gold=game.gold[seat], honor=game.table.seats[seat].honor, went_second=seat is not game.first_player, )
[docs] def opposing_states(game: GameState, seat: PlayerId) -> tuple[PlayerState, ...]: """The :class:`PlayerState` view for every seat other than ``seat``.""" return tuple(player_state(game, other) for other in game.table.seats if other is not seat)
# A gold-production handler computes what a card produces in context, from the producing card, its # controller's view, the opponents' views, and the cards being paid for. GoldHandler = Callable[[L5RCard, PlayerState, tuple[PlayerState, ...], tuple[L5RCard, ...]], int] GOLD_HANDLERS: dict[str, GoldHandler] = {}
[docs] def gold_handler(printed_id: str) -> Callable[[GoldHandler], GoldHandler]: """Register the decorated function as the gold-production handler for ``printed_id``.""" def register(handler: GoldHandler) -> GoldHandler: GOLD_HANDLERS[printed_id] = handler return handler return register
def _on_battlefield(game: GameState, card_id: str) -> bool: return any(card.id == card_id for card in game.table.battlefield.cards)
[docs] def active_modifiers(game: GameState, card: L5RCard, stat: Stat) -> Iterator[Modifier]: """Every modifier adjusting ``card``'s ``stat`` right now: one derived from each counter it holds (each counter is a source that grants its per-count stat while in play), plus the recorded modifiers targeting it — a ``WHILE_SOURCE_IN_PLAY`` one only while its source is on the battlefield.""" # A counter's source is the card itself, in play by construction here (this is only reached for # an in-play card), so no source-in-play check is needed for the derived modifiers. for counter in ALL_COUNTERS: per_count = getattr(counter, stat.value, 0) count = card.counters.get(counter.key, 0) if per_count and count: yield Modifier(card.id, card.id, stat, per_count * count, Duration.WHILE_SOURCE_IN_PLAY) for modifier in game.modifiers: if modifier.target_id != card.id or modifier.stat is not stat: continue if modifier.duration is Duration.WHILE_SOURCE_IN_PLAY and not _on_battlefield( game, modifier.source_id ): continue yield modifier
[docs] def effective_gold_production( game: GameState, card: L5RCard, targets: tuple[L5RCard, ...] = () ) -> int: """The gold ``card`` produces right now: its registered handler's result against the live views, or its printed ``gold_production`` when no handler is registered, plus every active Gold Production modifier on it (wealth counters, ability grants), floored at zero. A card with no Gold Production stat produces 0 and receives no modifiers (the stat is absent). Parameters ---------- game : GameState The live game the views project from. card : L5RCard The producing card. targets : tuple of L5RCard, optional The cards being paid for, for a handler whose yield depends on what it pays for. Default empty. """ handler = GOLD_HANDLERS.get(card.printed_id) if handler is None: if not hasattr(card, "gold_production"): return 0 # an absent stat cannot receive modifiers (CR, Absent Stats) base = card.gold_production else: base = handler( card, player_state(game, card.owner), opposing_states(game, card.owner), targets ) total = base + sum( modifier.amount for modifier in active_modifiers(game, card, Stat.GOLD_PRODUCTION) ) return max(0, total)
# A recruit-discount handler computes the gold reduction on recruiting a card, from the card being # recruited and its controller's and opponents' views. It reduces the card's own cost — the "enters # play for N less Gold" holdings, gated on a readable condition. DiscountHandler = Callable[[L5RCard, PlayerState, tuple[PlayerState, ...]], int] RECRUIT_DISCOUNTS: dict[str, DiscountHandler] = {}
[docs] def recruit_discount(printed_id: str) -> Callable[[DiscountHandler], DiscountHandler]: """Register the decorated function as the recruit-discount handler for ``printed_id``.""" def register(handler: DiscountHandler) -> DiscountHandler: RECRUIT_DISCOUNTS[printed_id] = handler return handler return register
[docs] def effective_recruit_discount(game: GameState, card: L5RCard) -> int: """The gold ``card`` costs less to recruit from its own conditional cost-reduction ability, or 0 when it has none.""" handler = RECRUIT_DISCOUNTS.get(card.printed_id) if handler is None: return 0 return handler(card, player_state(game, card.owner), opposing_states(game, card.owner))
def _is_clan(me: PlayerState, clan: str) -> bool: return me.stronghold is not None and me.stronghold.clan == clan # Per-card gold-production handlers, registered on import of this module. The read-sites already # load it, so a handler is always in place by the time gold is produced. @gold_handler("ancestral_estate") def _ancestral_estate( card: L5RCard, me: PlayerState, opponents: tuple[PlayerState, ...], targets: tuple[L5RCard, ...] ) -> int: """+1 GP while you are the second player.""" return card.gold_production + (1 if me.went_second else 0) @gold_handler("dockside_market") def _dockside_market( card: L5RCard, me: PlayerState, opponents: tuple[PlayerState, ...], targets: tuple[L5RCard, ...] ) -> int: """+1 GP for controlling any Port, and +1 GP for controlling another Market.""" bonus = (1 if me.controls("Port") else 0) + (1 if me.controls("Market", other_than=card) else 0) return card.gold_production + bonus @gold_handler("jade_works") def _jade_works( card: L5RCard, me: PlayerState, opponents: tuple[PlayerState, ...], targets: tuple[L5RCard, ...] ) -> int: """+2 GP when paying for a Jade card.""" bonus = 2 if any("Jade" in target.keywords for target in targets) else 0 return card.gold_production + bonus @gold_handler("teardrop_island") def _teardrop_island( card: L5RCard, me: PlayerState, opponents: tuple[PlayerState, ...], targets: tuple[L5RCard, ...] ) -> int: """Produce 2 Gold, or 3 while you are a Mantis Clan player.""" return 3 if _is_clan(me, "Mantis") else 2 @gold_handler("shrine_of_sincerity") def _shrine_of_sincerity( card: L5RCard, me: PlayerState, opponents: tuple[PlayerState, ...], targets: tuple[L5RCard, ...] ) -> int: """+1 GP when paying for a Sincerity card that still carries Sincerity tokens.""" bonus = ( 1 if any( "Sincerity" in target.keywords and target.counters.get(SINCERITY.key, 0) > 0 for target in targets ) else 0 ) return card.gold_production + bonus # Per-card recruit-discount handlers — the "enters play for N less Gold" holdings. @recruit_discount("colonial_farm") def _colonial_farm(card: L5RCard, me: PlayerState, opponents: tuple[PlayerState, ...]) -> int: """Enters play for 1 less Gold if you are a Lion Clan player.""" return 1 if _is_clan(me, "Lion") else 0 @recruit_discount("fantastic_gardens") def _fantastic_gardens(card: L5RCard, me: PlayerState, opponents: tuple[PlayerState, ...]) -> int: """Enters play for 2 less Gold if you are a Crane Clan player.""" return 2 if _is_clan(me, "Crane") else 0 @recruit_discount("moto_traders") def _moto_traders(card: L5RCard, me: PlayerState, opponents: tuple[PlayerState, ...]) -> int: """Enters play for 1 less Gold if you control another Merchant Caravan.""" return 1 if me.controls("Merchant Caravan", other_than=card) else 0 @recruit_discount("shrine_of_courtesy") def _shrine_of_courtesy(card: L5RCard, me: PlayerState, opponents: tuple[PlayerState, ...]) -> int: """Courtesy grants -3 Gold Cost while you are the second player (you did not go first).""" return 3 if me.went_second else 0