Source code for yasuki_core.engine.rules.abilities

from collections.abc import Callable
from dataclasses import dataclass

from yasuki_core.engine.players import PlayerId
from yasuki_core.engine.rules.modifiers import Duration, Stat
from yasuki_core.engine.rules.state import GameState, Phase
from yasuki_core.engine.rules.triggers import (
    AdjustCounter,
    BanishTopFate,
    Bow,
    Destroy,
    DrawCard,
    Effect,
    GrantModifier,
    Straighten,
    province_holdings,
    sincerity_seed_targets,
)
from yasuki_core.engine.table import DeckKey
from yasuki_core.game_pieces.cards import L5RCard
from yasuki_core.game_pieces.constants import Side
from yasuki_core.game_pieces.counters import SINCERITY, WEALTH
from yasuki_core.game_pieces.dynasty import DynastyHolding

# A cost is the effects paid to activate an ability, applied to the source card before the ability's
# own effects. Bow / destroy / spend-a-token are all just effects targeting the source, so costs and
# effects share one vocabulary — there is no separate cost taxonomy.
Cost = Callable[[L5RCard], list[Effect]]


def _bow(source: L5RCard) -> list[Effect]:
    return [Bow(source.id)]


def _destroy(source: L5RCard) -> list[Effect]:
    return [Destroy(source.id)]


def _spend_wealth(source: L5RCard) -> list[Effect]:
    return [AdjustCounter(source.id, WEALTH, -1)]


def _bow_and_destroy(source: L5RCard) -> list[Effect]:
    return [Bow(source.id), Destroy(source.id)]


def _banish_top_fate(source: L5RCard) -> list[Effect]:
    return [BanishTopFate(source.owner)]


[docs] def can_pay(game: GameState, card: L5RCard, cost: Cost) -> bool: """Whether ``card`` can pay ``cost`` — every effect it spends can actually apply: a bow needs the card unbowed, a counter spend needs enough of that counter, a Fate-deck banish needs a card to banish. Any other cost effect always applies.""" for effect in cost(card): match effect: case Bow() if card.bowed: return False case AdjustCounter(counter=counter, delta=delta) if delta < 0: if card.counters.get(counter.key, 0) < -delta: return False case BanishTopFate(seat=seat): if not game.table.decks[DeckKey(seat, Side.FATE)].cards: return False return True
[docs] @dataclass(frozen=True, slots=True) class Ability: """An activated ability on an in-play card. Attributes ---------- phase : Phase The phase the ability may be used in. label : str A short human description for the activation menu. cost : callable Maps the source card to the effects paid to activate — applied before the ability's own. targets : callable Maps ``(game, source_card)`` to the ids of the cards the ability may target — empty when none are legal, which also means the ability can't be offered. effects : callable Maps ``(source_card, target_card)`` to the effects the ability emits against a target. all_targets : bool Whether the ability hits every card ``targets`` returns rather than one chosen among them — an untargeted "your other Farms" grant instead of a single pick. Default False. recruits_target : bool Whether the ability recruits its chosen target (paying for it and bringing it into play) rather than emitting ``effects`` against it — Modest Farm's out-of-sequence recruit. Default False. """ phase: Phase label: str cost: Cost targets: Callable[[GameState, L5RCard], list[str]] effects: Callable[[L5RCard, L5RCard], list[Effect]] all_targets: bool = False recruits_target: bool = False
[docs] @dataclass(frozen=True, slots=True) class InvestAbility: """A card's Invest ability — an optional gold cost paid while recruiting for a one-time enter-play effect (the kicker-style second purchase option). Attributes ---------- minimum : int The least gold the Invest may cost; equals ``maximum`` for a fixed Invest. maximum : int The most gold the Invest may cost; above ``minimum`` for a variable Invest whose amount the recruiting seat chooses. effect : callable Maps ``(source_card, amount_paid)`` to the effects the Invest emits once the card enters play. """ minimum: int maximum: int effect: Callable[[L5RCard, int], list[Effect]]
[docs] def ability_for(card: L5RCard) -> Ability | None: """The activated ability registered for ``card``'s printed id, or None.""" return _ABILITIES.get(card.printed_id)
[docs] def invest_for(card: L5RCard) -> InvestAbility | None: """The Invest ability registered for ``card``'s printed id, or None.""" return _INVEST.get(card.printed_id)
[docs] def production_boost_for(card: L5RCard) -> int | None: """The extra Gold ``card`` may add as it bows to produce, or None if it has no boost. A boostable producer is destroyed after it bows boosted — Outlying Farms is the sole case.""" return _PRODUCTION_BOOST.get(card.printed_id)
[docs] def activatable(game: GameState, seat: PlayerId, phase: Phase) -> list[L5RCard]: """The in-play cards ``seat`` may activate an ability on right now: controlled, its ability legal in ``phase``, its cost payable, and with at least one legal target.""" ready: list[L5RCard] = [] for card in game.table.battlefield.cards: if card.owner is not seat: continue ability = _ABILITIES.get(card.printed_id) if ability is None or ability.phase is not phase: continue if not can_pay(game, card, ability.cost): continue if ability.targets(game, card): ready.append(card) return ready
def _owned_holdings(game: GameState, owner: PlayerId, keyword: str) -> list[DynastyHolding]: return [ held for held in game.table.battlefield.cards if held.owner is owner and isinstance(held, DynastyHolding) and keyword in held.keywords ] def _owned_farms(game: GameState, card: L5RCard) -> list[str]: return [farm.id for farm in _owned_holdings(game, card.owner, "Farm")] def _other_farms(game: GameState, card: L5RCard) -> list[str]: return [farm.id for farm in _owned_holdings(game, card.owner, "Farm") if farm is not card] def _owned_markets(game: GameState, card: L5RCard) -> list[str]: return [market.id for market in _owned_holdings(game, card.owner, "Market")] def _owned_ports(game: GameState, card: L5RCard) -> list[str]: return [port.id for port in _owned_holdings(game, card.owner, "Port")] def _sincerity_seed_targets(game: GameState, card: L5RCard) -> list[str]: return sincerity_seed_targets(game, card.owner) def _province_holdings(game: GameState, card: L5RCard) -> list[str]: return province_holdings(game, card.owner) def _no_effects(source: L5RCard, target: L5RCard) -> list[Effect]: return [] # a recruits_target ability routes to the recruit flow, never to effects def _owned_bowed_farms(game: GameState, card: L5RCard) -> list[str]: # "Not produced Gold this turn" is satisfied for any bowed Farm: production only happens in the # Dynasty phase, after this Open ability's Action-phase window. return [farm.id for farm in _owned_holdings(game, card.owner, "Farm") if farm.bowed] def _millet_farm_effects(source: L5RCard, target: L5RCard) -> list[Effect]: return [ GrantModifier(source.id, target.id, Stat.GOLD_PRODUCTION, 2, Duration.UNTIL_END_OF_TURN) ] def _otokoshi_effects(source: L5RCard, target: L5RCard) -> list[Effect]: return [DrawCard(source.owner), AdjustCounter(target.id, WEALTH, 1)] def _rural_market_effects(source: L5RCard, target: L5RCard) -> list[Effect]: return [Straighten(target.id)] def _seed_sincerity(source: L5RCard, target: L5RCard) -> list[Effect]: return [AdjustCounter(target.id, SINCERITY, 1)] def _plus_one_gp_this_turn(source: L5RCard, target: L5RCard) -> list[Effect]: return [ GrantModifier(source.id, target.id, Stat.GOLD_PRODUCTION, 1, Duration.UNTIL_END_OF_TURN) ] # Per-card activated abilities, registered on import. _ABILITIES: dict[str, Ability] = { "millet_farm": Ability( phase=Phase.ACTION, label="Bow: give a Farm +2 Gold Production", cost=_bow, targets=_owned_farms, effects=_millet_farm_effects, ), "otokoshi_district": Ability( phase=Phase.ACTION, label="Destroy: draw a card and give a Market a wealth token", cost=_destroy, targets=_owned_markets, effects=_otokoshi_effects, ), "rural_market": Ability( phase=Phase.ACTION, label="Spend a wealth token: straighten a Farm", cost=_spend_wealth, targets=_owned_bowed_farms, effects=_rural_market_effects, ), "harvested_land": Ability( phase=Phase.ACTION, label="Bow, destroy: give your other Farms +1 Gold Production", cost=_bow_and_destroy, targets=_other_farms, effects=_plus_one_gp_this_turn, all_targets=True, ), "ichiba_district": Ability( phase=Phase.ACTION, label="Banish a Fate card: give a Port +1 Gold Production", cost=_banish_top_fate, targets=_owned_ports, effects=_plus_one_gp_this_turn, ), "shrine_of_sincerity": Ability( phase=Phase.DYNASTY, label="Bow: seed a Sincerity token onto a Province Sincerity card", cost=_bow, targets=_sincerity_seed_targets, effects=_seed_sincerity, ), "modest_farm": Ability( phase=Phase.ACTION, label="Bow, pay a Holding's cost: recruit it from your Province out of sequence", cost=_bow, targets=_province_holdings, effects=_no_effects, recruits_target=True, ), } def _invest_wealth(source: L5RCard, amount: int) -> list[Effect]: """One +1GP Wealth token per gold invested — Rebuilt Harbor's variable payoff.""" return [AdjustCounter(source.id, WEALTH, amount)] def _two_wealth(source: L5RCard, amount: int) -> list[Effect]: return [AdjustCounter(source.id, WEALTH, 2)] def _one_wealth(source: L5RCard, amount: int) -> list[Effect]: return [AdjustCounter(source.id, WEALTH, 1)] # Per-card Invest abilities, registered on import. Courts of Otosan Uchi also creates a Courtier # Personality, deferred until personalities are recruitable; only its Wealth token is modeled. _INVEST: dict[str, InvestAbility] = { "questionable_market": InvestAbility(minimum=2, maximum=2, effect=_two_wealth), "rebuilt_harbor": InvestAbility(minimum=1, maximum=3, effect=_invest_wealth), "training_court": InvestAbility(minimum=1, maximum=1, effect=_one_wealth), "courts_of_otosan_uchi": InvestAbility(minimum=2, maximum=2, effect=_one_wealth), } # Producers that may raise their yield as they bow to pay, mapped to the extra Gold, then destroy # themselves. Outlying Farms ("+2GP, then destroy it after it bows") is the sole arc case. _PRODUCTION_BOOST: dict[str, int] = { "outlying_farms": 2, }