Source code for yasuki_core.engine.rules.flow

from yasuki_core.engine import ops
from yasuki_core.engine.players import PlayerId
from yasuki_core.engine.table import BATTLEFIELD, UNPLACED_BOARD_POS, DeckKey, ZoneKey, ZoneRole
from yasuki_core.engine.zones import ProvinceZone
from yasuki_core.game_pieces.cards import L5RCard
from yasuki_core.game_pieces.constants import Side
from yasuki_core.game_pieces.pregame import StrongholdCard, SenseiCard, WindCard
from yasuki_core.game_pieces.dynasty import DynastyHolding, DynastyPersonality
from yasuki_core.engine.rules.actions import (
    ActivateAbility,
    Action,
    DynastyDiscard,
    Legacy,
    Pass,
    Recruit,
)
from yasuki_core.engine.rules.state import GameState, Phase, TURN_PHASES
from yasuki_core.engine.rules.work import (
    ApplyAbilityEffects,
    FinishRecruit,
    ModestFarmStraighten,
    ResolveRecruit,
    ResumeCascade,
    SelectAbilityTarget,
    WorkItem,
)
from yasuki_core.engine.rules.decisions import (
    BanishForLegacy,
    ChooseAbilityTarget,
    ChooseCards,
    ChooseInvestAmount,
    ChooseLegacyCard,
    ChoosePayment,
    DiscardToHandSize,
    DecisionResponse,
    PlaceLegacy,
)
from yasuki_core.engine.rules.effects import effective_gold_production, effective_recruit_discount
from yasuki_core.engine.rules.modifiers import Duration
from yasuki_core.engine.rules import abilities, triggers
from yasuki_core.engine.rules.events import CardDiscarded, EnteredPlay, TurnStarted
from yasuki_core.game_pieces.counters import SINCERITY
from yasuki_core.ruleset import SHATTERED_EMPIRE

# The boldface keyword marking a card the Legacy rulebook ability can search out.
LEGACY_KEYWORD = "Legacy"

# The keyword that refills a card's vacated Province face-up when it enters play (rather than the
# usual face-down), so the next card is recruitable the same turn.
RENEW_KEYWORD = "Renew"

# The default maximum hand size, enforced by the end-of-turn discard (rules-skeleton §1).
MAX_HAND_SIZE = 8

# The active ruleset: legal Clan Alignments and the off-clan surcharge.
RULESET = SHATTERED_EMPIRE
OFF_CLAN_SURCHARGE = RULESET.off_clan_surcharge


[docs] def next_phase(phase: Phase) -> Phase | None: """Return the phase that follows ``phase`` within a turn, or None after the last phase (Dynasty), where the turn ends with the fate draw.""" index = TURN_PHASES.index(phase) return TURN_PHASES[index + 1] if index + 1 < len(TURN_PHASES) else None
# The pre-game permanents that get their enters-play effect fired as the game begins. _PREGAME_PERMANENTS = (StrongholdCard, SenseiCard, WindCard)
[docs] def begin_game(game: GameState) -> None: """Run the game-start pass once after :meth:`GameState.start`, before the active player acts: fire each pre-game permanent's enters-play effect, then the first turn's housekeeping. Re-runs on every replay, so those effects must be idempotent.""" _begin_pregame(game) _begin_turn(game)
def _begin_pregame(game: GameState) -> None: """Fire EnteredPlay for each pre-game permanent on the battlefield, so a Stronghold or Sensei with an ``@on(EnteredPlay, ...)`` trigger runs it as the game begins.""" for card in list(game.table.battlefield.cards): if isinstance(card, _PREGAME_PERMANENTS): triggers.fire(game, EnteredPlay(card.id))
[docs] def advance(game: GameState) -> None: """Advance the active player's turn to the next phase; past the Dynasty phase, run the end of the turn and begin the next. The gold pool empties on every phase change. Pause instead of finishing the turn if the end-of-turn discard needs an answer: record the request on ``game.pending`` and return, leaving the caller to :func:`submit` a response before advancing again. Raise ``RuntimeError`` if called while a decision is already pending. """ if game.awaiting_decision: raise RuntimeError("cannot advance while a decision is pending") game.clear_gold() following = next_phase(game.phase) if following is not None: game.phase = following return _end_turn(game)
[docs] def perform(game: GameState, action: Action) -> None: """Apply a chosen action: pass to end the phase, or recruit a card from a province. The single action-apply dispatch, mirroring :func:`submit` for decisions.""" match action: case Pass(): advance(game) case Recruit(card_id=card_id, invest=invest, proclaim=proclaim): recruit(game, card_id, invest, proclaim=proclaim) case DynastyDiscard(card_id=card_id): dynasty_discard(game, card_id) case Legacy(): legacy(game) case ActivateAbility(card_id=card_id): activate(game, card_id)
[docs] def produce_gold(game: GameState, card_id: str, amount: int) -> None: """Bow the card and add ``amount`` gold to its owner's pool — the yield the payment offer quoted for it (KD6). Gold is only produced while paying a cost (rules-skeleton §7), so a payment drives this.""" card = game.table.cards_by_id[card_id] card.bow() game.add_gold(card.owner, amount)
[docs] def gold_producers(game: GameState, seat: PlayerId) -> list[L5RCard]: """The unbowed gold producers ``seat`` controls in play — its Stronghold and gold Holdings — each a source it may bow for gold (KD6, stat-derived).""" return [ card for card in game.table.battlefield.cards if card.owner is seat and not card.bowed and effective_gold_production(game, card) > 0 ]
[docs] def reachable_gold(game: GameState, seat: PlayerId, card: L5RCard) -> int: """The gold ``seat`` could muster to recruit ``card``: its pool plus the yield of every unbowed producer — judged with ``card`` as the payment target since a producer's yield can depend on what it pays for — plus any bow-time boost a producer could add if the seat opts in.""" total = game.gold[seat] for producer in gold_producers(game, seat): total += effective_gold_production(game, producer, targets=(card,)) boost = abilities.production_boost_for(producer) if boost is not None: total += boost return total
def _clan_names(card: L5RCard) -> tuple[str, ...]: """The card's printed clan names: its :attr:`clans` list, or the lone ``clan`` when that is empty.""" if card.clans: return card.clans return (card.clan,) if card.clan else () def _card_alignments(card: L5RCard) -> set[str]: """The canonical Clan Alignment slugs ``card`` carries, dropping clan names that are not alignments in the active ruleset (minor clans, Shadowlands, "Unaligned", ...). Empty for an unaligned card.""" return {slug for name in _clan_names(card) if (slug := RULESET.alignment(name)) is not None} def _seat_alignment(game: GameState, seat: PlayerId | None) -> str | None: """The seat's Clan Alignment slug, taken from its Stronghold, or None when the Stronghold carries no legal alignment (an unaligned seat).""" clan = _seat_clan(game, seat) return RULESET.alignment(clan) if clan is not None else None
[docs] def recruit_cost(game: GameState, card: L5RCard) -> int: """The gold a seat pays to recruit ``card``: its printed gold cost, plus the off-clan surcharge when the card has a Clan Alignment the seat does not share, less the card's own conditional recruit discount. Floored at zero.""" cost = card.gold_cost or 0 seat_align = _seat_alignment(game, card.owner) card_aligns = _card_alignments(card) if seat_align is not None and card_aligns and seat_align not in card_aligns: cost += OFF_CLAN_SURCHARGE cost -= effective_recruit_discount(game, card) return max(0, cost)
[docs] def recruit( game: GameState, card_id: str, invest: bool = False, renew: bool = False, proclaim: bool = False, ) -> None: """Announce a Recruit: defer bringing the card into play, then pause for its cost payment. The payment bows gold producers to cover :func:`recruit_cost` plus any Invest cost; once answered, the stack resolves the move into play and the province refill. With ``invest`` set, also pay the card's Invest cost for its one-time enter-play effect. A fixed Invest folds straight into the payment; a variable one pauses first for :class:`ChooseInvestAmount` to pick how much to pay. With ``renew`` set, the vacated province refills face-up (a Renew granted by the recruiting effect). With ``proclaim`` set, claim the seat's once-per-turn Proclaim and add the Personality's Personal Honor to its Family Honor after it enters play (rules-skeleton §6); nothing is claimed until the payment resolves, so a cancelled Proclaim leaves it available. Raise ``ValueError`` if both ``invest`` and ``proclaim`` are set — Invest belongs to Holdings and Proclaim to Personalities, so no card offers both.""" if invest and proclaim: raise ValueError("a Recruit cannot both Invest and Proclaim") card = game.table.cards_by_id[card_id] seat = card.owner if not invest: _announce_recruit(game, card, seat, invest_amount=0, renew=renew, proclaim=proclaim) return ability = abilities.invest_for(card) if ability.minimum == ability.maximum: _announce_recruit(game, card, seat, invest_amount=ability.minimum, renew=renew) return affordable = reachable_gold(game, seat, card) - recruit_cost(game, card) top = min(ability.maximum, affordable) game.pending = ChooseInvestAmount( seat=seat, candidates=tuple(str(amount) for amount in range(ability.minimum, top + 1)), source_card_id=card_id, )
def _announce_recruit( game: GameState, card: L5RCard, seat: PlayerId, invest_amount: int, renew: bool = False, proclaim: bool = False, ) -> None: producers = gold_producers(game, seat) game.stack.append(ResolveRecruit(seat, card.id, invest_amount, renew, proclaim)) game.pending = ChoosePayment( seat=seat, candidates=tuple(producer.id for producer in producers), amount=recruit_cost(game, card) + invest_amount, available=game.gold[seat], produced=tuple( (producer.id, effective_gold_production(game, producer, targets=(card,))) for producer in producers ), label=card.name, boostable=tuple( (producer.id, boost) for producer in producers if (boost := abilities.production_boost_for(producer)) is not None ), ) def _apply_invest_amount( game: GameState, request: ChooseInvestAmount, response: DecisionResponse ) -> None: card = game.table.cards_by_id[request.source_card_id] game.pending = None _announce_recruit(game, card, card.owner, invest_amount=int(response.choices[0]))
[docs] def submit(game: GameState, response: DecisionResponse) -> None: """Answer the pending decision and resume the engine. Dispatch on the request type to its apply-handler, then continue: an end-of-turn discard begins the next turn, while a cost payment drains the stack to finish the action that paused for it. Raise ``RuntimeError`` if no decision is pending, or ``ValueError`` if the answer is malformed or illegal against the game state. """ request = game.pending if request is None: raise RuntimeError("no decision is pending") if not request.accepts(response): raise ValueError("malformed answer to the pending decision") match request: case DiscardToHandSize(): _apply_discard(game, request.seat, response.choices) game.pending = None _begin_next_turn(game) case ChoosePayment(): _apply_payment(game, request, response) game.pending = None run_stack(game) case BanishForLegacy(): _apply_legacy_banish(game, request, response) case ChooseLegacyCard(): _apply_legacy_choice(game, request, response) case PlaceLegacy(): _apply_legacy_placement(game, request, response) case ChooseAbilityTarget(): _apply_ability_target(game, request, response) case ChooseCards(): _apply_card_choice(game, request, response) case ChooseInvestAmount(): _apply_invest_amount(game, request, response) case _: raise ValueError(f"no handler for decision {type(request).__name__}")
[docs] def cancel(game: GameState) -> None: """Back out of the pending decision, undoing the action that raised it. A Recruit's steps are cancellable, since nothing is committed until the payment is answered — no gold spent, no producer bowed, the card still in its province. Cancelling a payment drops its deferred :class:`ResolveRecruit`; cancelling an Invest amount drops the choice before the recruit is even announced. Raise ``RuntimeError`` if no decision is pending, or ``ValueError`` if the pending decision cannot be cancelled. """ request = game.pending if request is None: raise RuntimeError("no decision is pending") match request: case ChoosePayment(): _cancel_recruit_payment(game) case ChooseInvestAmount(): game.pending = None # the recruit is not yet announced; nothing to undo case _: raise ValueError(f"{type(request).__name__} cannot be cancelled")
def _cancel_recruit_payment(game: GameState) -> None: if not game.stack or not isinstance(game.stack[-1], ResolveRecruit): raise ValueError("the pending payment has no recruit to undo") game.stack.pop() game.pending = None
[docs] def run_stack(game: GameState) -> None: """Drain deferred work, running each item until the stack empties or one pauses for a decision. A work item may itself emit a decision (setting ``pending``), so resolution stops there and resumes on the next :func:`submit`.""" while game.stack and game.pending is None: _resolve(game, game.stack.pop())
def _resolve(game: GameState, item: WorkItem) -> None: match item: case ResolveRecruit( seat=seat, card_id=card_id, invest_amount=invest_amount, renew=renew, proclaim=proclaim ): _resolve_recruit(game, seat, card_id, invest_amount, renew=renew, proclaim=proclaim) case SelectAbilityTarget(card_id=card_id, candidates=candidates): owner = game.table.cards_by_id[card_id].owner game.pending = ChooseAbilityTarget( seat=owner, candidates=candidates, source_card_id=card_id ) case ApplyAbilityEffects(card_id=card_id, target_ids=target_ids): source = game.table.cards_by_id[card_id] ability = abilities.ability_for(source) effects = [ effect for target_id in target_ids for effect in ability.effects(source, game.table.cards_by_id[target_id]) ] triggers.resolve_effects(game, effects) case FinishRecruit(card_id=card_id, invest_amount=invest_amount, proclaim=proclaim): _finish_recruit(game, card_id, invest_amount, proclaim=proclaim) case ModestFarmStraighten(modest_farm_id=modest_farm_id, target_id=target_id): owner = game.table.cards_by_id[target_id].owner triggers.resolve_effects( game, [ triggers.Choose( owner, (modest_farm_id,), 0, 1, "modest_farm_straighten", target_id ) ], ) case _: raise ValueError(f"no resolver for work item {type(item).__name__}") def _apply_payment(game: GameState, request: ChoosePayment, response: DecisionResponse) -> None: """Bow the chosen producers to cover the cost. A producer the answer boosted yields its extra as it bows and is then destroyed (Outlying Farms); the rest yield their plain amount.""" produced = dict(request.produced) boost = dict(request.boostable) boosted = set(response.boosted) for card_id in response.choices: extra = boost[card_id] if card_id in boosted else 0 produce_gold(game, card_id, produced[card_id] + extra) if card_id in boosted: triggers.resolve_effects(game, [triggers.Destroy(card_id)]) game.spend_gold(request.seat, request.amount) def _resolve_recruit( game: GameState, seat: PlayerId, card_id: str, invest_amount: int = 0, renew: bool = False, proclaim: bool = False, ) -> None: card = game.table.cards_by_id[card_id] province = _province_of(game, seat, card_id) # Enter unplaced so the client clusters the new card into the seat's home row by the stronghold, # rather than dropping it at the origin. ops.move_card(game.table, card, BATTLEFIELD, position=UNPLACED_BOARD_POS) if isinstance(card, DynastyHolding): card.bow() # Holdings enter play bowed; Personalities enter unbowed (rules-skeleton §6) if province is not None: refill = ops.fill_province(game.table, seat, province) if refill is not None and (renew or RENEW_KEYWORD in card.keywords): refill.turn_face_up() # Renew: the vacated Province refills face-up # Defer the post-entry steps so an enter-play trait that pauses for a choice resolves first. game.stack.append(FinishRecruit(card_id, invest_amount, proclaim)) triggers.fire(game, EnteredPlay(card_id)) def _finish_recruit( game: GameState, card_id: str, invest_amount: int, proclaim: bool = False ) -> None: card = game.table.cards_by_id[card_id] _clear_sincerity(game, card) if proclaim: game.use_once(proclaim_key(card.owner, game.turn)) ops.set_honor(game.table, card.owner, delta=card.personal_honor) if invest_amount: triggers.resolve_effects(game, abilities.invest_for(card).effect(card, invest_amount)) def _clear_sincerity(game: GameState, card: L5RCard) -> None: """Remove a card's Sincerity tokens once it has entered play — its trait has already read them during the ``EnteredPlay`` cascade (Sincerity keyword).""" held = card.counters.get(SINCERITY.key, 0) if held: card.adjust_counter(SINCERITY.key, -held)
[docs] def dynasty_discard(game: GameState, card_id: str) -> None: """Discard a face-up province card to its owner's dynasty discard and refill the province — the Dynasty Discard action. It has no cost, so it resolves at once with no payment.""" card = game.table.cards_by_id[card_id] seat = card.owner province = _province_of(game, seat, card_id) ops.move_card(game.table, card, ZoneKey(seat, ZoneRole.DYNASTY_DISCARD)) if province is not None: ops.fill_province(game.table, seat, province) triggers.fire(game, CardDiscarded(card_id, card.side, seat))
[docs] def proclaim_key(seat: PlayerId, turn: int) -> str: """The once-per-turn usage key for a seat's Proclaim, scoped to the turn so it resets each turn without clearing ``GameState.once_per``.""" return f"proclaim:{seat.name}:{turn}"
[docs] def can_proclaim(game: GameState, card: L5RCard) -> bool: """Whether recruiting ``card`` could be Proclaimed by its seat: a Personality carrying the seat's Clan Alignment that the seat has not yet Proclaimed against this turn.""" if not isinstance(card, DynastyPersonality): return False seat = card.owner seat_align = _seat_alignment(game, seat) if seat_align is None or seat_align not in _card_alignments(card): return False return not game.has_used(proclaim_key(seat, game.turn))
[docs] def legacy_key(seat: PlayerId, turn: int) -> str: """The once-per-turn usage key for a seat's Legacy ability, scoped to the turn so it resets each turn without clearing ``GameState.once_per``.""" return f"legacy:{seat.name}:{turn}"
[docs] def is_legacy_card(card: L5RCard) -> bool: """Whether ``card`` carries the Legacy keyword, so the Legacy ability can search it out.""" return any(keyword.lower() == LEGACY_KEYWORD.lower() for keyword in card.keywords)
[docs] def legacy_search_pool(game: GameState, seat: PlayerId) -> list[L5RCard]: """Every card ``seat``'s Legacy search looks through: its whole dynasty deck plus the face-down (unrevealed) cards in its provinces. Face-up province cards are already recruitable and are not searched. This is the pool a search dialog shows.""" pool = list(game.table.decks[DeckKey(seat, Side.DYNASTY)].cards) for key, zone in game.table.zones.items(): if key.owner is seat and key.role is ZoneRole.PROVINCE: pool.extend(card for card in zone.cards if not card.face_up) return pool
[docs] def legacy_candidates(game: GameState, seat: PlayerId) -> list[L5RCard]: """The Legacy cards ``seat`` could find right now — the Legacy cards within its search pool. Empty means a Legacy search would whiff and lose the game.""" return [card for card in legacy_search_pool(game, seat) if is_legacy_card(card)]
[docs] def legacy(game: GameState) -> None: """Announce the Legacy ability: claim its once-per-turn use and pause for the banish cost. The search and placement follow once the banished card is chosen.""" seat = game.active game.use_once(legacy_key(seat, game.turn)) hand = game.table.zones[ZoneKey(seat, ZoneRole.HAND)] game.pending = BanishForLegacy(seat=seat, candidates=tuple(card.id for card in hand.cards))
def _apply_legacy_banish( game: GameState, request: BanishForLegacy, response: DecisionResponse ) -> None: seat = request.seat banished = game.table.cards_by_id[response.choices[0]] ops.move_card(game.table, banished, ZoneKey(seat, ZoneRole.FATE_BANISH)) game.pending = None found = legacy_candidates(game, seat) if not found: game.loser = seat # the whiff: failing to find a Legacy card loses the game return game.pending = ChooseLegacyCard(seat=seat, candidates=tuple(card.id for card in found)) def _apply_legacy_choice( game: GameState, request: ChooseLegacyCard, response: DecisionResponse ) -> None: seat = request.seat legacy_card = game.table.cards_by_id[response.choices[0]] game.pending = None provinces = _displaceable_provinces(game, seat, keep=legacy_card.id) if not provinces: # No province to sacrifice — only reachable at zero provinces (a military loss the engine # does not model yet). Reveal the found card where it sits rather than placing it. legacy_card.turn_face_up() return game.pending = PlaceLegacy(seat=seat, candidates=provinces, legacy_card_id=legacy_card.id) def _apply_legacy_placement( game: GameState, request: PlaceLegacy, response: DecisionResponse ) -> None: seat = request.seat displaced = game.table.cards_by_id[response.choices[0]] legacy_card = game.table.cards_by_id[request.legacy_card_id] target_key = _province_key_of(game, seat, displaced.id) source_zone = _province_of(game, seat, legacy_card.id) # None when it came from the deck ops.move_card(game.table, displaced, ZoneKey(seat, ZoneRole.DYNASTY_DISCARD)) ops.move_card(game.table, legacy_card, target_key) legacy_card.turn_face_up() # a placed Legacy card enters its province revealed game.pending = None if source_zone is None: game.table.decks[DeckKey(seat, Side.DYNASTY)].shuffle(seed=game.seed + game.turn) else: ops.fill_province(game.table, seat, source_zone) triggers.fire(game, CardDiscarded(displaced.id, displaced.side, seat)) def _displaceable_provinces(game: GameState, seat: PlayerId, *, keep: str) -> tuple[str, ...]: """The province cards ``seat`` may discard to make room for a placed Legacy card — its face card in each province, skipping the province that already holds the found card (id ``keep``), which cannot be its own sacrifice.""" displaceable: list[str] = [] for key, zone in game.table.zones.items(): if key.owner is not seat or key.role is not ZoneRole.PROVINCE or not zone.cards: continue if any(card.id == keep for card in zone.cards): continue displaceable.append(zone.cards[-1].id) return tuple(displaceable)
[docs] def activate(game: GameState, card_id: str) -> None: """Announce an activated ability: pay its cost, then resolve its target — a single chosen card, or every card it hits for an ``all_targets`` ability. The ability is guaranteed registered and to have a legal target — ``legal_actions`` only offers it then. Resolving the target is deferred behind the cost on the stack, so a cost whose own cascade pauses for a decision resolves fully first. Targets are fixed before paying (Good Faith): the candidates are the ones ``legal_actions`` validated, so the ability is never left with nothing to hit.""" card = game.table.cards_by_id[card_id] ability = abilities.ability_for(card) if ability.recruits_target: targets = tuple(recruitable_via_ability(game, card)) else: targets = tuple(ability.targets(game, card)) deferred = ( ApplyAbilityEffects(card_id, targets) if ability.all_targets else SelectAbilityTarget(card_id, targets) ) game.stack.append(deferred) triggers.resolve_effects(game, ability.cost(card)) run_stack(game) # resolve the target, unless the cost's cascade paused for a decision first
def _apply_ability_target( game: GameState, request: ChooseAbilityTarget, response: DecisionResponse ) -> None: source = game.table.cards_by_id[request.source_card_id] target = game.table.cards_by_id[response.choices[0]] ability = abilities.ability_for(source) game.pending = None if ability.recruits_target: _recruit_via_ability(game, source, target) else: triggers.resolve_effects(game, ability.effects(source, target))
[docs] def recruitable_via_ability(game: GameState, source: L5RCard) -> list[str]: """The face-up Province Holdings ``source``'s recruit ability can afford to bring into play. The seat pays each target's :func:`recruit_cost` from its pool and unbowed producers, minus ``source``'s own yield: the ability bows or destroys ``source`` as its cost, so ``source`` can no longer produce toward the recruit.""" seat = source.owner recruitable: list[str] = [] for card_id in triggers.province_holdings(game, seat): card = game.table.cards_by_id[card_id] forfeited = 0 if source.bowed else effective_gold_production(game, source, targets=(card,)) if recruit_cost(game, card) <= reachable_gold(game, seat, card) - forfeited: recruitable.append(card_id) return recruitable
def _recruit_via_ability(game: GameState, source: L5RCard, target: L5RCard) -> None: """Resolve Modest Farm's out-of-sequence recruit: pay for and bring the targeted Province Holding into play (its province refills face-up if it is a Farm), then offer to destroy Modest Farm to straighten it. The straighten offer is deferred behind the recruit's payment and entry.""" game.stack.append(ModestFarmStraighten(source.id, target.id)) recruit(game, target.id, renew="Farm" in target.keywords) # Modest Farm grants a Farm Renew def _apply_card_choice(game: GameState, request: ChooseCards, response: DecisionResponse) -> None: game.pending = None item = game.stack.pop() # the ResumeCascade this choice paused, always stacked atop it if not isinstance(item, ResumeCascade): raise RuntimeError("a card choice resumed without its stashed cascade") resolver = triggers.CHOICE_RESOLVERS[request.resolver] triggers.resume_cascade(game, item, resolver(game, request.source_id, response.choices)) run_stack(game) # finish any work deferred behind the choice, unless it paused again def _end_turn(game: GameState) -> None: seat = game.active _accrue_sincerity(game, seat) ops.draw_to_hand(game.table, seat) hand = game.table.zones[ZoneKey(seat, ZoneRole.HAND)] excess = len(hand.cards) - MAX_HAND_SIZE if excess > 0: candidates = tuple(card.id for card in hand.cards) game.pending = DiscardToHandSize(seat, candidates, count=excess) return _begin_next_turn(game) def _accrue_sincerity(game: GameState, seat: PlayerId) -> None: """Before ``seat``'s turn ends, give each face-up Sincerity card lingering in its Provinces a Sincerity token. A card that flushed (was recruited or discarded) or arrived face-down as a refill this turn is not face-up in a Province, so it does not accrue.""" grants = [ triggers.AdjustCounter(card.id, SINCERITY, 1) for key, zone in game.table.zones.items() if key.owner is seat and key.role is ZoneRole.PROVINCE for card in zone.cards if card.face_up and triggers.SINCERITY_KEYWORD in card.keywords ] triggers.resolve_effects(game, grants) def _begin_next_turn(game: GameState) -> None: # Drop until-end-of-turn modifiers as the turn ends; the comprehension keeps creation order so # the list rebuilds identically under replay. game.modifiers = [m for m in game.modifiers if m.duration is not Duration.UNTIL_END_OF_TURN] game.turn += 1 game.active = _other(game.active) game.phase = Phase.ACTION _begin_turn(game) def _begin_turn(game: GameState) -> None: ops.straighten(game.table, game.active) ops.reveal_provinces(game.table, game.active) triggers.fire(game, TurnStarted(game.active)) def _apply_discard(game: GameState, seat: PlayerId, card_ids: tuple[str, ...]) -> None: hand = game.table.zones[ZoneKey(seat, ZoneRole.HAND)] by_id = {card.id: card for card in hand.cards} missing = [card_id for card_id in card_ids if card_id not in by_id] if missing: raise ValueError(f"discard names cards not in {seat.name}'s hand: {missing}") for card_id in card_ids: card = by_id[card_id] ops.move_card(game.table, card, ZoneKey(seat, ZoneRole.FATE_DISCARD)) triggers.fire(game, CardDiscarded(card_id, card.side, seat)) def _seat_clan(game: GameState, seat: PlayerId) -> str | None: for card in game.table.battlefield.cards: if card.owner is seat and isinstance(card, StrongholdCard): return card.clan return None def _province_of(game: GameState, seat: PlayerId, card_id: str) -> ProvinceZone | None: for key, zone in game.table.zones.items(): if key.owner is seat and key.role is ZoneRole.PROVINCE: if any(card.id == card_id for card in zone.cards): return zone return None def _province_key_of(game: GameState, seat: PlayerId, card_id: str) -> ZoneKey: for key, zone in game.table.zones.items(): if key.owner is seat and key.role is ZoneRole.PROVINCE: if any(card.id == card_id for card in zone.cards): return key raise ValueError(f"no province of {seat.name} holds card {card_id}") def _other(seat: PlayerId) -> PlayerId: return PlayerId.P2 if seat is PlayerId.P1 else PlayerId.P1