diff --git a/spirit/game/card_effects/attacks_common.py b/spirit/game/card_effects/attacks_common.py new file mode 100644 index 0000000..dfd7f18 --- /dev/null +++ b/spirit/game/card_effects/attacks_common.py @@ -0,0 +1,689 @@ +"""Reusable attack-effect factories: flips, scaling, snipes, conditions, +energy-discard costs, locks, counter placement. + +Every factory returns an `async def effect(ctx)` for Attack(effect=...); +damage/base None = the attack's printed damage; `also=` chains a follow-up +coroutine run after the factory's own work. +""" + +import random +from typing import Optional + +from spirit.game.attributes import ( + AbilityTypes, + AttrID, + CLIENT_SPECIAL_CONDITION_NAMES, + TrainerType, +) +from spirit.game.data_utils import Attack, def_for, has_rule_box, is_pokemon_v, subtypes_for +from spirit.game.card_effects.pokemon import energy_provides_type +from spirit.game.session.effects import is_special_energy +from spirit.game.session.legal_actions import energy_provided_count + +_TOOL_TYPES = (TrainerType.POKEMON_TOOL.value, TrainerType.POKEMON_TOOL_F.value) +_ENERGY_SCOPES = ("self", "attacker", "defender", "opponent_active", "my_active", + "mine", "opponent", "both") + + +# ---------------------------------------------------------------------- +# Shared internals +# ---------------------------------------------------------------------- + +def _printed(ctx) -> int: + return getattr(ctx.ability, "damage", 0) or 0 + + +def _title(ctx) -> str: + return ctx.ability.title if ctx.ability else "" + + +def _resolve_count(ctx, count_or_fn) -> int: + return count_or_fn(ctx) if callable(count_or_fn) else int(count_or_fn) + + +def _in_play(ctx, side: str) -> list: + if side == "both": + return ctx.my_pokemon_in_play() + ctx.opponent_pokemon_in_play() + if side in ("opponent", "theirs"): + return ctx.opponent_pokemon_in_play() + return ctx.my_pokemon_in_play() + + +def _bench_of(ctx, side: str) -> list: + if side == "both": + return ctx.my_bench() + ctx.opponent_bench() + return ctx.opponent_bench() if side in ("opponent", "theirs") else ctx.my_bench() + + +def _side_pid(ctx, side: str) -> str: + return ctx.opponent_id if side in ("opponent", "theirs") else ctx.player_id + + +def _resolve_target(ctx, target_fn): + if callable(target_fn): + return target_fn(ctx) + return ctx.attacker if target_fn == "self" else ctx.defender + + +async def _finish(ctx, self_damage: int, also): + if self_damage: + await ctx.deal_damage(self_damage, target=ctx.attacker, apply_modifiers=False) + if also is not None: + await also(ctx) + + +def _attack_ability_entries(pokemon) -> list: + return [e for e in (pokemon.get_attribute(AttrID.PIE_ABILITIES) or []) + if isinstance(e, dict) and e.get("abilityType") == "Attack" + and e.get("abilityID")] + + +def _provided_of_type(energy, type_value: int) -> int: + info = energy.get_attribute(AttrID.ENERGY_INFO) or {} + best = max((option.count(type_value) + for option in info.get("options", [])), default=0) + return best or 1 + + +# ---------------------------------------------------------------------- +# Count helpers (each returns count_fn(ctx) -> int, or a card predicate) +# ---------------------------------------------------------------------- + +def count_energy(scope: str = "self", energy_type=None, cards: bool = False): + """Attached Energy on a scope; counts PROVIDED amounts (Double Turbo = 2) + unless cards=True. energy_type filters/counts one type (Aurora matches).""" + if scope not in _ENERGY_SCOPES: + raise ValueError(f"count_energy: unknown scope '{scope}'") + type_value = getattr(energy_type, "value", energy_type) + + def count(ctx) -> int: + if scope in ("self", "attacker"): + pool = [ctx.attacker] + elif scope in ("defender", "opponent_active"): + pool = [ctx.opponent_active()] + elif scope == "my_active": + pool = [ctx.my_active()] + else: + pool = _in_play(ctx, scope) + total = 0 + for pokemon in pool: + if pokemon is None: + continue + for energy in ctx.attached_energies(pokemon): + if type_value is None: + total += 1 if cards else energy_provided_count(energy) + elif energy_provides_type(energy, type_value): + total += 1 if cards else _provided_of_type(energy, type_value) + return total + return count + + +def count_bench(side: str = "mine", pred=None): + """Benched Pokemon on a side ('mine'|'opponent'|'both'), optionally filtered.""" + def count(ctx) -> int: + return sum(1 for p in _bench_of(ctx, side) if pred is None or pred(p)) + return count + + +def count_discard(side: str = "mine", pred=None): + """Cards in a discard pile ('mine'|'opponent'|'both'), optionally filtered.""" + def count(ctx) -> int: + piles = [ctx.player_id, ctx.opponent_id] if side == "both" \ + else [_side_pid(ctx, side)] + return sum(1 for pid in piles for c in ctx.discard_pile(pid) + if pred is None or pred(c)) + return count + + +def count_prizes_taken(side: str = "mine"): + def count(ctx) -> int: + return ctx.prizes_taken(_side_pid(ctx, side)) + return count + + +def count_prizes_remaining(side: str = "mine"): + def count(ctx) -> int: + area = ctx.board.find_player_area(_side_pid(ctx, side), "prizePile") + return len(area.children) if area else 0 + return count + + +def damage_counters_on(target_fn="self"): + """Damage counters on a Pokemon ('self'|'defender'|callable(ctx)->pokemon).""" + def count(ctx) -> int: + target = _resolve_target(ctx, target_fn) + if target is None: + return 0 + return max(0, (ctx.max_hp(target) - target.get_attribute(AttrID.HP, 0)) // 10) + return count + + +def count_hand(side: str = "mine"): + def count(ctx) -> int: + return ctx.hand_size(_side_pid(ctx, side)) + return count + + +def count_in_play(side: str = "mine", pred=None): + """In-play Pokemon (Active + Bench) on a side, optionally filtered.""" + def count(ctx) -> int: + return sum(1 for p in _in_play(ctx, side) if pred is None or pred(p)) + return count + + +def has_attack_titled(title: str): + """Card predicate: the card's definition carries an attack named `title` + (Mad Party / Let's All Rollout counting).""" + def pred(card) -> bool: + definition = def_for(getattr(card, "archetype_id", None) or "") + for ability in getattr(definition, "abilities", None) or []: + if getattr(ability, "title", None) != title: + continue + if isinstance(ability, Attack) or getattr(ability, "ability_type", None) \ + in (AbilityTypes.ATTACK, AbilityTypes.NON_DAMAGING_ATTACK): + return True + return False + return pred + + +# ---------------------------------------------------------------------- +# Flip family +# ---------------------------------------------------------------------- + +def flip_damage(coins: int = 1, per_heads: int = 0, base: Optional[int] = None, + bonus: int = 0, bonus_per_heads: int = 0, + until_tails: bool = False, coins_from=None, + require_all: bool = False, tails_self_damage: int = 0, + self_damage: int = 0, also=None): + """Coin-flip damage: heads*per_heads (base defaults 0), or printed base + + heads*bonus_per_heads, or base+bonus gated on ALL heads (require_all; + without a bonus the whole base needs all heads). coins_from=count_fn + flips one coin per counted thing; until_tails uses one coin screen. + tails_self_damage hits the attacker once when any coin lands tails.""" + async def effect(ctx): + if until_tails: + heads = await ctx.flip_until_tails(_title(ctx)) + total, tails = heads + 1, 1 + else: + total = _resolve_count(ctx, coins_from) if coins_from is not None else coins + results = await ctx.flip_coins(total, _title(ctx)) if total > 0 else [] + heads = sum(1 for r in results if r) + tails = total - heads + base_val = (base or 0) if per_heads \ + else (base if base is not None else _printed(ctx)) + if require_all: + success = total > 0 and tails == 0 + amount = base_val + bonus if success else (base_val if bonus else 0) + else: + amount = base_val + heads * (per_heads + bonus_per_heads) \ + + (bonus if heads else 0) + if amount > 0: + await ctx.deal_damage(amount) + if tails_self_damage and tails > 0: + await ctx.deal_damage(tails_self_damage, target=ctx.attacker, + apply_modifiers=False) + await _finish(ctx, self_damage, also) + return effect + + +def flip_or_nothing(coins: int = 1, then=None): + """"Flip a coin. If tails, this attack does nothing." — any tails is an + early return; all heads runs `then(ctx)` (default: printed damage).""" + async def effect(ctx): + results = await ctx.flip_coins(coins, _title(ctx)) + if not results or not all(results): + return + if then is None: + await ctx.deal_damage() + else: + await then(ctx) + return effect + + +def flip_bonus(bonus: int, coins: int = 1): + """Printed damage, +bonus when every flipped coin is heads.""" + return flip_damage(coins=coins, bonus=bonus, require_all=True) + + +# ---------------------------------------------------------------------- +# Scaling damage +# ---------------------------------------------------------------------- + +def damage_per(count_fn, per: int, base: int = 0, cap: Optional[int] = None, + self_damage: int = 0, also=None): + """base + per * count_fn(ctx) damage to the opponent's Active (cap optional).""" + async def effect(ctx): + amount = base + per * max(0, count_fn(ctx)) + if cap is not None: + amount = min(amount, cap) + if amount > 0: + await ctx.deal_damage(amount) + await _finish(ctx, self_damage, also) + return effect + + +# ---------------------------------------------------------------------- +# Snipe / spread +# ---------------------------------------------------------------------- + +def snipe_attack(amount: int, pool="bench", count: int = 1, + side: str = "opponent", also_base: bool = False, + apply_modifiers: Optional[bool] = None, optional: bool = False, + self_damage: int = 0, also=None, prompt: Optional[str] = None): + """Deal `amount` to `count` chosen Pokemon (pool 'bench'|'any'|predicate). + apply_modifiers None = engine auto (W/R only when the target is the + opponent's Active); also_base deals printed damage to the Active first.""" + async def effect(ctx): + if also_base: + await ctx.deal_damage() + if pool == "any": + candidates = _in_play(ctx, side) + elif pool == "bench": + candidates = _bench_of(ctx, side) + else: + candidates = [p for p in _in_play(ctx, side) if pool(p)] + if candidates: + text = prompt or (f"Choose a Pokémon to take {amount} damage" + if count == 1 else + f"Choose {count} Pokémon to take {amount} damage") + picks = await ctx.choose_cards( + candidates, count, minimum=0 if optional else None, prompt=text) + for target in picks: + await ctx.deal_damage(amount, target=target, + apply_modifiers=apply_modifiers) + await _finish(ctx, self_damage, also) + return effect + + +def spread_damage(amount: int, side: str = "opponent", + include_active: bool = False, own_bench: bool = False, + also_base: bool = False, also=None): + """Deal `amount` to every Benched Pokemon on `side` ('opponent'|'mine'| + 'both'); include_active adds that side's Active(s), own_bench adds your + bench to an opponent-side spread. Bench damage takes no W/R (engine auto).""" + async def effect(ctx): + if also_base: + await ctx.deal_damage() + targets = {p.entity_id: p for p in _bench_of(ctx, side)} + if own_bench and side != "mine": + targets.update((p.entity_id, p) for p in ctx.my_bench()) + if include_active: + actives = [] + if side in ("opponent", "theirs", "both"): + actives.append(ctx.opponent_active()) + if side in ("mine", "both"): + actives.append(ctx.my_active()) + targets.update((p.entity_id, p) for p in actives if p is not None) + for target in targets.values(): + await ctx.deal_damage(amount, target=target, apply_modifiers=None) + if also is not None: + await also(ctx) + return effect + + +def damage_all_opponents(amount: int, also=None): + """Deal `amount` to each of the opponent's Pokemon (Active included).""" + return spread_damage(amount, side="opponent", include_active=True, also=also) + + +# ---------------------------------------------------------------------- +# Conditional bonus + predicates +# ---------------------------------------------------------------------- + +def bonus_if(cond_fn, bonus: int, base: Optional[int] = None, + else_nothing: bool = False, self_damage: int = 0, also=None): + """Printed (or base) damage, +bonus when cond_fn(ctx) holds; else_nothing + makes the whole attack deal 0 when the condition fails.""" + async def effect(ctx): + base_val = base if base is not None else _printed(ctx) + met = bool(cond_fn(ctx)) + amount = base_val + bonus if met else (0 if else_nothing else base_val) + if amount > 0: + await ctx.deal_damage(amount) + await _finish(ctx, self_damage, also) + return effect + + +def active_is(pred): + """cond_fn: the opponent's Active exists and matches `pred(pokemon)`.""" + def cond(ctx) -> bool: + active = ctx.opponent_active() + return active is not None and bool(pred(active)) + return cond + + +def defender_is_v(ctx) -> bool: + d = ctx.defender + return d is not None and is_pokemon_v(d.archetype_id) + + +def defender_is_gx(ctx) -> bool: + d = ctx.defender + return d is not None and "GX" in subtypes_for(d.archetype_id) + + +def defender_is_vmax(ctx) -> bool: + d = ctx.defender + return d is not None and "VMAX" in subtypes_for(d.archetype_id) + + +def defender_has_rule_box(ctx) -> bool: + d = ctx.defender + return d is not None and has_rule_box(d.archetype_id) + + +def defender_has_condition(condition): + """cond_fn: the opponent's Active already has the Special Condition.""" + name = CLIENT_SPECIAL_CONDITION_NAMES[condition] + + def cond(ctx) -> bool: + d = ctx.defender + return d is not None and \ + name in (d.get_attribute(AttrID.SPECIAL_CONDITIONS) or []) + return cond + + +def named_in_play(*names, side: str = "mine", require_all: bool = False): + """cond_fn: Pokemon with these names (EVOLUTION_LOGIC_NAME) are in play.""" + def cond(ctx) -> bool: + present = {p.get_attribute(AttrID.EVOLUTION_LOGIC_NAME) + for p in _in_play(ctx, side)} + wanted = set(names) + return wanted <= present if require_all else bool(wanted & present) + return cond + + +def has_tool(pokemon) -> bool: + """Card predicate: a Tool is attached (compose: active_is(has_tool)).""" + return any(c.get_attribute(AttrID.TRAINER_TYPE) in _TOOL_TYPES + for c in pokemon.children) + + +def has_damage(target_fn="defender"): + """cond_fn: the target ('self'|'defender'|callable) has damage on it.""" + def cond(ctx) -> bool: + target = _resolve_target(ctx, target_fn) + return target is not None and \ + target.get_attribute(AttrID.HP, 0) < ctx.max_hp(target) + return cond + + +def opponent_prizes_taken_at_least(n: int): + def cond(ctx) -> bool: + return ctx.prizes_taken(ctx.opponent_id) >= n + return cond + + +# ---------------------------------------------------------------------- +# Energy-discard costs +# ---------------------------------------------------------------------- + +def self_energy_discard_attack(count: Optional[int] = None, + all_energy: bool = False, energy_type=None, + before_damage: bool = False, + then_damage: Optional[int] = None, also=None): + """Discard own attached Energy (count, or all_energy=True) around the + printed (or then_damage) damage; before_damage follows the printed order.""" + if count is None and not all_energy: + raise ValueError("self_energy_discard_attack: pass count= or all_energy=True") + type_value = getattr(energy_type, "value", energy_type) + pred = (lambda c: energy_provides_type(c, type_value)) \ + if type_value is not None else None + + async def _discard(ctx): + await ctx.discard_energy_from( + ctx.attacker, 99 if all_energy else count, predicate=pred, + prompt="Choose Energy to discard from this Pokémon") + + async def effect(ctx): + if before_damage: + await _discard(ctx) + await ctx.deal_damage(then_damage) + else: + await ctx.deal_damage(then_damage) + await _discard(ctx) + if also is not None: + await also(ctx) + return effect + + +def discard_opponent_energy_attack(count: int = 1, special_only: bool = False, + after_damage: bool = True, + damage: Optional[int] = None, also=None): + """Printed (or damage) damage + discard Energy from the opponent's Active; + the discard no-ops when the target is shielded from attack effects.""" + pred = is_special_energy if special_only else None + + async def _discard(ctx): + target = ctx.opponent_active() + if target is None or ctx.effects_blocked(target): + return + await ctx.discard_energy_from( + target, count, predicate=pred, + prompt="Choose Energy to discard from the Defending Pokémon") + + async def effect(ctx): + if after_damage: + await ctx.deal_damage(damage) + await _discard(ctx) + else: + await _discard(ctx) + await ctx.deal_damage(damage) + if also is not None: + await also(ctx) + return effect + + +# ---------------------------------------------------------------------- +# Special-condition attacks (supersedes card_effects.pokemon.condition_attack) +# ---------------------------------------------------------------------- + +def condition_attack(*conditions, flip: bool = False, coins: int = 1, + min_heads: int = 1, tails_conditions=(), + always_conditions=(), heads_bonus_damage: int = 0, + self_conditions=(), both_actives: bool = False, + no_retreat: bool = False, counters: int = 1, + checkup_coins: int = 1, damage: Optional[int] = None, + also=None): + """Printed damage + Special Conditions on the Defending Pokemon; flip=True + gates `conditions`/self_conditions/no_retreat/heads_bonus_damage on + min_heads heads (tails_conditions on failure, always_conditions always).""" + async def effect(ctx): + success = True + if flip: + results = await ctx.flip_coins(coins, _title(ctx)) + success = sum(1 for r in results if r) >= min_heads + base_val = damage if damage is not None else _printed(ctx) + amount = base_val + (heads_bonus_damage if success else 0) + if amount > 0: + await ctx.deal_damage(amount) + applied = list(always_conditions) + \ + (list(conditions) if success else list(tails_conditions)) + for condition in applied: + await ctx.apply_special_condition( + ctx.defender, condition, + checkup_coins=checkup_coins, poison_counters=counters) + if both_actives: + await ctx.apply_special_condition( + ctx.attacker, condition, + checkup_coins=checkup_coins, poison_counters=counters) + if success: + for condition in self_conditions: + await ctx.apply_special_condition( + ctx.attacker, condition, + checkup_coins=checkup_coins, poison_counters=counters) + if no_retreat: + defender = ctx.defender + if defender is not None and not ctx.effects_blocked(defender): + ctx.lock_retreat(defender) + if also is not None: + await also(ctx) + return effect + + +def condition_bonus_attack(bonus: int, *required_conditions, + base: Optional[int] = None, also=None): + """"If the Defending Pokemon is already , this attack does + `bonus` more damage." Checks before the hit; applies nothing itself.""" + names = [CLIENT_SPECIAL_CONDITION_NAMES[c] for c in required_conditions] + + async def effect(ctx): + base_val = base if base is not None else _printed(ctx) + defender = ctx.defender + current = (defender.get_attribute(AttrID.SPECIAL_CONDITIONS) or []) \ + if defender is not None else [] + met = bool(names) and all(n in current for n in names) + amount = base_val + (bonus if met else 0) + if amount > 0: + await ctx.deal_damage(amount) + if also is not None: + await also(ctx) + return effect + + +# ---------------------------------------------------------------------- +# Recoil / mill +# ---------------------------------------------------------------------- + +def recoil_attack(self_damage: int, damage: Optional[int] = None, also=None): + """Printed (or damage) damage; the attacker then damages itself (no W/R).""" + async def effect(ctx): + await ctx.deal_damage(damage) + await ctx.deal_damage(self_damage, target=ctx.attacker, + apply_modifiers=False) + if also is not None: + await also(ctx) + return effect + + +def mill_attack(count: int, opponent: bool = True, + then_damage: Optional[int] = None, also=None): + """Printed (or then_damage) damage, then discard the top `count` cards of + a deck (opponent's by default).""" + async def effect(ctx): + await ctx.deal_damage(then_damage) + pid = ctx.opponent_id if opponent else ctx.player_id + await ctx.discard_cards(ctx.deck_top(count, player_id=pid)) + if also is not None: + await also(ctx) + return effect + + +def mill_scaled_damage(mill_count: int, per: int, pred=None, base: int = 0, + also=None): + """Discard the top `mill_count` of YOUR deck; damage = base + per for each + discarded card matching `pred`.""" + async def effect(ctx): + cards = ctx.deck_top(mill_count) + await ctx.discard_cards(cards) + matched = sum(1 for c in cards if pred is None or pred(c)) + amount = base + per * matched + if amount > 0: + await ctx.deal_damage(amount) + if also is not None: + await also(ctx) + return effect + + +# ---------------------------------------------------------------------- +# Attack-lock helpers (runtime helpers, not factories) +# ---------------------------------------------------------------------- + +def lock_all_attacks(ctx, pokemon): + """Locks every printed attack on own `pokemon` through its user's next + turn (Cross Fusion Strike's blanket self-lock).""" + for entry in _attack_ability_entries(pokemon): + ctx.session.turn_state.lock_attack(pokemon.entity_id, entry["abilityID"]) + + +def lock_defender_attacks(ctx, defender=None) -> bool: + """"The Defending Pokemon can't attack during your opponent's next turn"; + no-ops (False) on an effect-shielded target.""" + target = defender if defender is not None else ctx.defender + if target is None or ctx.effects_blocked(target): + return False + state = ctx.session.turn_state + for entry in _attack_ability_entries(target): + state.attack_locks[(target.entity_id, entry["abilityID"])] = \ + state.turn_number + 1 + return True + + +# ---------------------------------------------------------------------- +# Damage-counter placement +# ---------------------------------------------------------------------- + +def place_counters(count_or_fn, target: str = "opponent_active", also=None): + """Put damage counters (raw, no W/R; effect-shield gated by the engine): + target 'opponent_active'|'choose_any_opponent'|'each_opponent'|'self'| + 'choose_own'; count_or_fn is an int or count_fn(ctx).""" + if target not in ("opponent_active", "choose_any_opponent", "each_opponent", + "self", "choose_own"): + raise ValueError(f"place_counters: unknown target '{target}'") + + async def effect(ctx): + count = _resolve_count(ctx, count_or_fn) + if count > 0: + if target == "opponent_active": + if ctx.defender is not None: + await ctx.deal_damage(count * 10, target=ctx.defender, + as_counters=True) + elif target == "choose_any_opponent": + await ctx.place_damage_counters(count, ctx.opponent_pokemon_in_play()) + elif target == "each_opponent": + for pokemon in ctx.opponent_pokemon_in_play(): + await ctx.deal_damage(count * 10, target=pokemon, + as_counters=True) + elif target == "self": + await ctx.deal_damage(count * 10, target=ctx.attacker, + as_counters=True) + else: # choose_own + await ctx.place_damage_counters(count, ctx.my_pokemon_in_play()) + if also is not None: + await also(ctx) + return effect + + +# ---------------------------------------------------------------------- +# Discard-for-bonus + misc +# ---------------------------------------------------------------------- + +def discard_for_bonus(source: str = "hand", predicate=None, max_count: int = 1, + per: int = 0, flat: int = 0, optional: bool = True, + base: Optional[int] = None, also=None, + prompt: Optional[str] = None): + """Discard up to `max_count` cards (from 'hand' or 'self-energy'), then + deal printed (or base) + per*discarded + (flat if any discarded).""" + if source not in ("hand", "self-energy"): + raise ValueError(f"discard_for_bonus: unknown source '{source}'") + + async def effect(ctx): + if source == "hand": + pool = [c for c in ctx.hand() if predicate is None or predicate(c)] + else: + pool = [c for c in ctx.attached_energies(ctx.attacker) + if predicate is None or predicate(c)] + picked = await ctx.choose_cards( + pool, max_count, minimum=0 if optional else None, + prompt=prompt or "Choose cards to discard") if pool else [] + await ctx.discard_cards(picked) + base_val = base if base is not None else _printed(ctx) + amount = base_val + per * len(picked) + (flat if picked else 0) + if amount > 0: + await ctx.deal_damage(amount) + if also is not None: + await also(ctx) + return effect + + +async def discard_random_from_hand(ctx, player_id: Optional[str] = None, + count: int = 1) -> list: + """Discards `count` random cards from a player's hand (reveals via the + public-pile discard bracket); returns the discarded cards.""" + pid = player_id or ctx.player_id + hand = ctx.hand(pid) + if not hand or count <= 0: + return [] + picks = random.sample(hand, min(count, len(hand))) + await ctx.discard_cards(picks) + return picks diff --git a/spirit/game/card_effects/passives_common.py b/spirit/game/card_effects/passives_common.py new file mode 100644 index 0000000..51a1678 --- /dev/null +++ b/spirit/game/card_effects/passives_common.py @@ -0,0 +1,628 @@ +"""Reusable Passive factories and temporary-shield effect factories. + +GROUP A builds configured Passive instances for Ability(passive=)/Tool/ +Stadium/Energy definitions. GROUP B builds complete attack effects (printed +damage first, then the rider) around expiring temp passives; the apply_* +helpers are the damage-free riders for composition inside bespoke effects. + +Predicate conventions: `protects` is 'carrier' | 'team' | callable(target, +carrier). Side/holder gates are built into each factory, so refining preds +(attacker_pred / target_pred / holder_pred) take just the entity; block-hook +preds (no_retreat / ability_lock / healing_block / retreat_free) take +(target, carrier). +""" + +from typing import Any, Callable, Optional + +from spirit.game.attributes import AttrID +from spirit.game.session.passives import ( + Passive, + TurnDamageModifier, + carrier_pokemon, +) + + +# ---------------------------------------------------------------------- +# Shared predicate plumbing +# ---------------------------------------------------------------------- + +def _protects_pred(protects) -> Callable[[Any, Any], bool]: + """Resolves the 'carrier' | 'team' | callable(target, carrier) tri-form.""" + if callable(protects): + return protects + if protects == "team": + return lambda target, carrier: ( + target.owning_player_id == carrier.owning_player_id + ) + if protects in (None, "carrier"): + return lambda target, carrier: carrier_pokemon(carrier) is target + raise ValueError(f"protects must be 'carrier', 'team' or a callable: {protects!r}") + + +def is_in_active_spot(pokemon) -> bool: + """Whether a top-level Pokemon sits in its owner's Active spot.""" + parent = getattr(pokemon, "parent", None) + return bool(parent) and parent.get_attribute(AttrID.NAME) == "activePokemonArea" + + +def opposing_pokemon(target, carrier) -> bool: + """(target, carrier) pred: target belongs to the other player.""" + return target.owning_player_id != carrier.owning_player_id + + +def opposing_active(target, carrier) -> bool: + """(target, carrier) pred: target is the opposing Active Pokemon.""" + return opposing_pokemon(target, carrier) and is_in_active_spot(target) + + +# ====================================================================== +# GROUP A -- continuous Passive factories +# ====================================================================== + +class TakesLessPassive(Passive): + """Protected Pokemon take N less damage from opposing attacks (after W/R).""" + + def __init__(self, amount, protects="carrier", attacker_pred=None, + stack_key=None): + self.amount = amount + self.protects = _protects_pred(protects) + self.attacker_pred = attacker_pred + self.stack_key = stack_key + + def modify_damage_taken(self, calc, carrier): + if not (calc.is_attack and calc.is_opposing): + return + if not self.protects(calc.target, carrier): + return + if self.attacker_pred is not None and ( + calc.attacker is None or not self.attacker_pred(calc.attacker)): + return + if self.stack_key: + if self.stack_key in calc.applied_once: + return + calc.applied_once.add(self.stack_key) + calc.amount = max(0, calc.amount - self.amount) + + +def takes_less_passive(amount, protects="carrier", attacker_pred=None, + stack_key=None) -> Passive: + """"Takes N less damage from attacks" (Lesson in Zeal shape); a stack_key + dedups multiple copies within one damage calc.""" + return TakesLessPassive(amount, protects, attacker_pred, stack_key) + + +class TeamDamageBoostPassive(Passive): + """Matching friendly attackers' attacks do +N (before W/R).""" + + def __init__(self, amount, attacker_pred=None, target_pred=None, + once_key=None, to_active_only=True): + self.amount = amount + self.attacker_pred = attacker_pred + self.target_pred = target_pred + self.once_key = once_key + self.to_active_only = to_active_only + + def modify_damage_dealt(self, calc, carrier): + attacker = calc.attacker + if not (calc.is_attack and calc.is_opposing and attacker is not None): + return + if attacker.owning_player_id != carrier.owning_player_id: + return + if self.attacker_pred is not None and not self.attacker_pred(attacker): + return + if self.to_active_only and not calc.to_active: + return + if self.target_pred is not None and not self.target_pred(calc.target): + return + if self.once_key: + if self.once_key in calc.applied_once: + return + calc.applied_once.add(self.once_key) + calc.amount += self.amount + + +def team_damage_boost_passive(amount, attacker_pred=None, target_pred=None, + once_key=None, to_active_only=True) -> Passive: + """"Your Pokemon's attacks do +N to the opponent's Active" + (pass to_active_only=False for boosts with no Active clause).""" + return TeamDamageBoostPassive(amount, attacker_pred, target_pred, + once_key, to_active_only) + + +class PredicatePreventionPassive(Passive): + """prevents_damage generic: pred(calc, carrier) decides the block.""" + + def __init__(self, pred, attacks_only=True): + self.pred = pred + self.attacks_only = attacks_only + + def prevents_damage(self, calc, carrier): + if self.attacks_only and not (calc.is_attack and calc.is_opposing): + return False + return bool(self.pred(calc, carrier)) + + +def prevent_damage_when(pred, attacks_only=True) -> Passive: + """Full damage prevention whenever pred(calc, carrier) holds; by default + only versus opposing attacks (Wave Veil discipline).""" + return PredicatePreventionPassive(pred, attacks_only) + + +class TypedDamageBoostPassive(Passive): + """The holder's attacks do +N to opposing targets matching a type/pred.""" + + def __init__(self, type_or_pred, amount, to_active_only=True): + if callable(type_or_pred): + self.target_match = type_or_pred + else: + type_value = getattr(type_or_pred, "value", type_or_pred) + self.target_match = lambda target: type_value in ( + target.get_attribute(AttrID.POKEMON_TYPES) or [] + ) + self.amount = amount + self.to_active_only = to_active_only + + def modify_damage_dealt(self, calc, carrier): + if not (calc.is_attack and calc.is_opposing): + return + if carrier_pokemon(carrier) is not calc.attacker: + return + if self.to_active_only and not calc.to_active: + return + if self.target_match(calc.target): + calc.amount += self.amount + + +def typed_damage_boost_tool(type_or_pred, amount, to_active_only=True) -> Passive: + """Gloves shape: the holder's attacks do +N versus opposing Active + targets of the given PokemonTypes (or matching target_pred(target)).""" + return TypedDamageBoostPassive(type_or_pred, amount, to_active_only) + + +class HpBonusPassive(Passive): + """The holder gets +N max HP (Cape of Toughness shape).""" + + def __init__(self, amount, holder_pred=None): + self.amount = amount + self.holder_pred = holder_pred + + def max_hp_bonus(self, pokemon, carrier): + if carrier_pokemon(carrier) is not pokemon: + return 0 + if self.holder_pred is not None and not self.holder_pred(pokemon): + return 0 + return self.amount + + +def hp_bonus_tool(amount, holder_pred=None) -> Passive: + """+N max HP on the holder; holder_pred gates eligibility (e.g. Basic + only for Cape of Toughness). Engine keeps damage-taken constant.""" + return HpBonusPassive(amount, holder_pred) + + +class RetreatDiscountPassive(Passive): + """Matching Pokemon's retreat cost is N less.""" + + def __init__(self, amount, target_pred=None): + self.amount = amount + self.protects = _protects_pred(target_pred) + + def modify_retreat_cost(self, cost, pokemon, carrier): + if not self.protects(pokemon, carrier): + return cost + return max(0, cost - self.amount) + + +def retreat_discount(amount, target_pred=None) -> Passive: + """Retreat cost -N (Air Balloon shape); target_pred defaults to the + carrier's holder, or 'team' / callable(pokemon, carrier).""" + return RetreatDiscountPassive(amount, target_pred) + + +class RetreatFreeWhenPassive(Passive): + """Retreat cost becomes 0 whenever pred(pokemon, carrier) holds.""" + + def __init__(self, pred): + self.pred = pred + + def modify_retreat_cost(self, cost, pokemon, carrier): + return 0 if self.pred(pokemon, carrier) else cost + + +def retreat_free_when(pred) -> Passive: + """Free retreat while pred(pokemon, carrier) holds.""" + return RetreatFreeWhenPassive(pred) + + +class OpponentAttackTaxPassive(Passive): + """Opposing Pokemon's attacks cost [C] N more.""" + + def __init__(self, extra, target_pred=None): + self.extra = extra + self.target_pred = target_pred + + def modify_attack_cost(self, cost, pokemon, carrier, board): + if pokemon.owning_player_id == carrier.owning_player_id: + return cost + if self.target_pred is not None and not self.target_pred(pokemon): + return cost + cost["Colorless"] = cost.get("Colorless", 0) + self.extra + return cost + + +def opponent_attack_tax(extra_cost_count, target_pred=None) -> Passive: + """Opposing (vs the carrier's owner) Pokemon's attacks cost [C] N more; + target_pred(pokemon) refines which opposing Pokemon are taxed.""" + return OpponentAttackTaxPassive(extra_cost_count, target_pred) + + +class AttackDiscountPassive(Passive): + """Attacks cost [C] N less (Colorless removed first, Excited Heart shape).""" + + def __init__(self, count, self_only=True, pred=None): + self.count = count + self.self_only = self_only + self.pred = pred + + def modify_attack_cost(self, cost, pokemon, carrier, board): + if self.self_only: + if carrier_pokemon(carrier) is not pokemon: + return cost + elif pokemon.owning_player_id != carrier.owning_player_id: + return cost + if self.pred is not None and not self.pred(pokemon): + return cost + if "Colorless" not in cost: + return cost + remaining = cost["Colorless"] - self.count + if remaining > 0: + cost["Colorless"] = remaining + else: + del cost["Colorless"] + return cost + + +def attack_discount_passive(count, self_only=True, pred=None) -> Passive: + """Attacks cost [C] N less; self_only=False widens to the carrier + owner's whole team, pred(pokemon) refines further.""" + return AttackDiscountPassive(count, self_only, pred) + + +class NoWeaknessPassive(Passive): + """Protected Pokemon have no Weakness.""" + + def __init__(self, protects="carrier"): + self.protects = _protects_pred(protects) + + def modify_weakness(self, calc, carrier): + if self.protects(calc.target, carrier): + calc.weakness_applies = False + + +def no_weakness_passive(protects="carrier") -> Passive: + """"... has no Weakness" (Mysterious Nest shape via a protects pred).""" + return NoWeaknessPassive(protects) + + +class WeaknessMultiplierPassive(Passive): + """Weakness applies xN instead of x2 (Supereffective Glasses shape).""" + + def __init__(self, mult, when=None): + self.mult = mult + self.when = when + + def modify_weakness(self, calc, carrier): + if self.when is not None: + if not self.when(calc, carrier): + return + elif carrier_pokemon(carrier) is not calc.attacker: + return + calc.weakness_multiplier = self.mult + + +def weakness_multiplier_passive(mult, when=None) -> Passive: + """Rewrites the Weakness multiplier; defaults to the holder attacking, + or gate with when(calc, carrier).""" + return WeaknessMultiplierPassive(mult, when) + + +class NoResistancePassive(Passive): + """Attacks from the carrier's side aren't affected by Resistance.""" + + def __init__(self, when=None): + self.when = when + + def modify_resistance(self, calc, carrier): + if self.when is not None: + if not self.when(calc, carrier): + return + elif not (calc.attacker is not None + and calc.attacker.owning_player_id == carrier.owning_player_id): + return + calc.resistance_applies = False + + +def no_resistance_passive(when=None) -> Passive: + """"... isn't affected by Resistance" (Pinsir team shape); defaults to + the carrier owner's attackers, or gate with when(calc, carrier).""" + return NoResistancePassive(when) + + +class ConditionImmunityPassive(Passive): + """Protected Pokemon can't be affected by the given Special Conditions.""" + + def __init__(self, conditions=None, protects="carrier"): + if conditions is None: + self.conditions = None # None = immune to ALL conditions + elif isinstance(conditions, (list, tuple, set, frozenset)): + self.conditions = set(conditions) + else: + self.conditions = {conditions} + self.protects = _protects_pred(protects) + + def blocks_special_conditions(self, target, condition, carrier): + if self.conditions is not None and condition not in self.conditions: + return False + return bool(self.protects(target, carrier)) + + +def condition_immunity_passive(conditions=None, protects="carrier") -> Passive: + """Condition immunity (Galarian Rapidash shape); conditions=None means + ALL, else a SpecialConditions member or iterable of them.""" + return ConditionImmunityPassive(conditions, protects) + + +class NoRetreatPassive(Passive): + """Matching Pokemon can't retreat.""" + + def __init__(self, target_pred): + self.target_pred = target_pred + + def blocks_retreat(self, pokemon, carrier): + return bool(self.target_pred(pokemon, carrier)) + + +def no_retreat_passive(target_pred) -> Passive: + """Blocks retreat while target_pred(pokemon, carrier) holds (Flygon: + `lambda p, c: opposing_active(p, c) and is_in_active_spot(c)`).""" + return NoRetreatPassive(target_pred) + + +class AbilityLockPassive(Passive): + """Matching Pokemon have no Abilities.""" + + def __init__(self, target_pred): + self.target_pred = target_pred + + def blocks_abilities(self, pokemon, carrier): + return bool(self.target_pred(pokemon, carrier)) + + +def ability_lock_passive(target_pred) -> Passive: + """Turns off Abilities while target_pred(pokemon, carrier) holds (Path + to the Peak: `lambda p, c: has_rule_box(p.archetype_id)`).""" + return AbilityLockPassive(target_pred) + + +class HealingBlockPassive(Passive): + """Matching Pokemon can't have damage healed.""" + + def __init__(self, target_pred): + self.target_pred = target_pred + + def prevents_healing(self, target, carrier): + return bool(self.target_pred(target, carrier)) + + +def healing_block_passive(target_pred) -> Passive: + """Blocks healing while target_pred(target, carrier) holds (Mimikyu).""" + return HealingBlockPassive(target_pred) + + +class AttackEffectShieldPassive(Passive): + """Protected Pokemon are shielded from opposing attack EFFECTS.""" + + def __init__(self, protects="carrier"): + self.protects = _protects_pred(protects) + + def blocks_attack_effects(self, target, carrier): + return bool(self.protects(target, carrier)) + + +def attack_effect_shield_passive(protects="carrier") -> Passive: + """Unfazed Fat generalized: shields from attack effects, not damage + (the engine already scopes the check to opposing attacks).""" + return AttackEffectShieldPassive(protects) + + +# ====================================================================== +# GROUP B -- temporary-shield effect factories (expiring temp passives) +# ====================================================================== +# Factory forms are COMPLETE attack effects: printed damage first (a no-op +# at damage 0), then the rider. The apply_* helpers are riders only. + +class TempShieldPassive(Passive): + """reduce-N / prevent / effects_too shield on its carrier vs opposing attacks.""" + + def __init__(self, reduce=None, prevent=False, effects_too=False): + self.reduce = reduce + self.prevent = prevent + self.effects_too = effects_too + + def modify_damage_taken(self, calc, carrier): + if (self.reduce and calc.is_attack and calc.is_opposing + and carrier_pokemon(carrier) is calc.target): + calc.amount = max(0, calc.amount - self.reduce) + + def prevents_damage(self, calc, carrier): + return bool(self.prevent and calc.is_attack and calc.is_opposing + and carrier_pokemon(carrier) is calc.target) + + def blocks_attack_effects(self, target, carrier): + return bool(self.effects_too and carrier_pokemon(carrier) is target) + + +async def apply_protection(ctx, target=None, reduce=None, prevent=False, + effects_too=False, through_own_next_turn=False): + """Rider: shield `target` (default the attack's user) through the + opponent's next turn; the shield ends early if the carrier leaves the + Active spot / play (clear_pokemon_effects).""" + target = target if target is not None else ctx.attacker + if target is None: + return False + shield = TempShieldPassive(reduce=reduce, prevent=prevent, + effects_too=effects_too) + if through_own_next_turn: + ctx.add_passive_through_own_next_turn(target, shield) + else: + ctx.add_passive_through_opponents_turn(target, shield) + return True + + +def protect_next_turn(reduce=None, prevent=False, effects_too=False, + self_target=True): + """Complete attack effect: printed damage, then shield the user (or the + Defending Pokemon when self_target=False) during the opponent's next + turn -- reduce=N takes-less, prevent=True full prevention, effects_too + adds the attack-effect shield ("all effects ... including damage").""" + async def effect(ctx): + await ctx.deal_damage() + target = ctx.attacker if self_target else ctx.defender + await apply_protection(ctx, target=target, reduce=reduce, + prevent=prevent, effects_too=effects_too) + return effect + + +def flip_protection(prevent=True, reduce=None, effects_too=False, title=None): + """Complete attack effect: printed damage, then flip a coin -- heads + shields the user during the opponent's next turn.""" + async def effect(ctx): + await ctx.deal_damage() + flip_title = title if title is not None else ( + ctx.ability.title if ctx.ability else "") + results = await ctx.flip_coins(1, flip_title) + if results and results[0]: + await apply_protection(ctx, reduce=reduce, prevent=prevent, + effects_too=effects_too) + return effect + + +class AttackDebuffPassive(Passive): + """The carrier's attacks do N less damage (dealt-side, before W/R).""" + + def __init__(self, amount): + self.amount = amount + + def modify_damage_dealt(self, calc, carrier): + if (calc.is_attack and calc.is_opposing + and carrier_pokemon(carrier) is calc.attacker): + calc.amount -= self.amount + + +async def apply_defender_debuff(ctx, amount, target=None): + """Rider: `target`'s (default the Defending Pokemon) attacks do N less + during the opponent's next turn; fizzles vs attack-effect shields.""" + target = target if target is not None else ctx.defender + if target is None or ctx.effects_blocked(target): + return False + ctx.add_passive_through_opponents_turn(target, AttackDebuffPassive(amount)) + return True + + +def debuff_defender_attacks(amount): + """Complete attack effect: printed damage, then "during your opponent's + next turn, the Defending Pokemon's attacks do N less damage".""" + async def effect(ctx): + await ctx.deal_damage() + await apply_defender_debuff(ctx, amount) + return effect + + +async def apply_own_next_turn_boost(ctx, amount, attack_title=None, + opposing_active_only=True): + """Rider: during your next turn this Pokemon's attacks (optionally only + `attack_title`) do +N; a turn guard keeps it off the current attack.""" + state = ctx.session.turn_state + start = state.turn_number + ctx.add_turn_damage_modifier(TurnDamageModifier( + amount, ctx.player_id, + opposing_active_only=opposing_active_only, + expires_after_turn=start + 2, + source_entity_id=ctx.attacker.entity_id, + attack_title=attack_title, + source_predicate=lambda _e: state.turn_number > start, + )) + + +def boost_own_next_turn(amount, attack_title=None): + """Complete attack effect: printed damage, then "during your next turn, + this Pokemon's attacks do +N" (Fullmetal Impact rider when titled).""" + async def effect(ctx): + await ctx.deal_damage() + await apply_own_next_turn_boost(ctx, amount, attack_title) + return effect + + +class SelfAttackCostRaisePassive(Passive): + """The carrier's attacks cost [C] N more.""" + + def __init__(self, extra): + self.extra = extra + + def modify_attack_cost(self, cost, pokemon, carrier, board): + if carrier_pokemon(carrier) is not pokemon: + return cost + cost["Colorless"] = cost.get("Colorless", 0) + self.extra + return cost + + +async def apply_defender_attack_cost_raise(ctx, extra=1, target=None): + """Rider: `target`'s (default the Defending Pokemon) attacks cost [C] + N more during the opponent's next turn; fizzles vs effect shields.""" + target = target if target is not None else ctx.defender + if target is None or ctx.effects_blocked(target): + return False + ctx.add_passive_through_opponents_turn( + target, SelfAttackCostRaisePassive(extra)) + return True + + +def raise_defender_attack_cost_next_turn(extra=1): + """Complete attack effect: printed damage, then the Defending Pokemon's + attacks cost [C] N more during your opponent's next turn.""" + async def effect(ctx): + await ctx.deal_damage() + await apply_defender_attack_cost_raise(ctx, extra) + return effect + + +class SelfRetreatCostRaisePassive(Passive): + """The carrier's retreat cost is [C] N more.""" + + def __init__(self, extra): + self.extra = extra + + def modify_retreat_cost(self, cost, pokemon, carrier): + if carrier_pokemon(carrier) is not pokemon: + return cost + return cost + self.extra + + +async def apply_defender_retreat_cost_raise(ctx, extra=1, target=None): + """Rider: `target`'s (default the Defending Pokemon) retreat cost is N + more during the opponent's next turn; fizzles vs effect shields.""" + target = target if target is not None else ctx.defender + if target is None or ctx.effects_blocked(target): + return False + ctx.add_passive_through_opponents_turn( + target, SelfRetreatCostRaisePassive(extra)) + return True + + +def raise_defender_retreat_cost_next_turn(extra=1): + """Complete attack effect: printed damage, then the Defending Pokemon's + retreat cost is [C] N more during your opponent's next turn.""" + async def effect(ctx): + await ctx.deal_damage() + await apply_defender_retreat_cost_raise(ctx, extra) + return effect diff --git a/spirit/game/card_effects/support_common.py b/spirit/game/card_effects/support_common.py new file mode 100644 index 0000000..4db09d6 --- /dev/null +++ b/spirit/game/card_effects/support_common.py @@ -0,0 +1,609 @@ +"""Reusable search / draw / heal / switch / energy effect factories. + +Every factory returns an `async def effect(ctx)` usable on attacks, abilities +AND trainers (TrainerCardDef(effect=...)); attack usages resolve the printed +damage first (text order). The matching playability `condition=` factories at +the bottom accept both the trainer (board, player_id) and the ability +(board, player_id, pokemon) call shapes. +""" + +from typing import Callable, List, Optional + +from spirit.game.attributes import AttrID +from spirit.game.data_utils import def_for +from spirit.game.models.board import CardEntity, PokemonEntity +from spirit.game.session.constants import BENCH_CAPACITY +from spirit.game.session.effects import ( + full_stack, + is_basic_pokemon, + is_trainer_card, +) +from spirit.game.session.passives import effective_max_hp +from spirit.game.card_effects.trainers import is_energy_card + +# Spec-friendly aliases used as factory defaults. +is_basic = is_basic_pokemon +is_energy = is_energy_card + + +# --- Internal helpers -------------------------------------------------------- + +async def _deal_printed(ctx): + """Printed attack damage first (text order); no-op for abilities/trainers.""" + if ctx.is_attack_effect() and getattr(ctx.ability, "damage", 0) > 0: + await ctx.deal_damage() + + +def _card_label(card: CardEntity) -> str: + definition = def_for(card.archetype_id) + return getattr(definition, "display_name", None) or "the card" + + +def _is_damaged(board, pokemon: PokemonEntity) -> bool: + return pokemon.get_attribute(AttrID.HP, 0) < effective_max_hp(board, pokemon) + + +def _has_conditions(pokemon: PokemonEntity) -> bool: + return bool(pokemon.get_attribute(AttrID.SPECIAL_CONDITIONS)) + + +def _opponent_of(board, player_id): + return next((p for p in board.player_ids if p != player_id), None) + + +async def _attach_all(ctx, cards, target: PokemonEntity): + if target.entity_id not in ctx.visual_targets: + ctx.visual_targets.append(target.entity_id) + for card in cards: + await ctx.attach_energy(card, target) + + +# --- Deck searches ----------------------------------------------------------- + +def search_to_hand(predicate=None, count=1, minimum=0, reveal=True, prompt=""): + """Search the deck for up to `count` matches into hand, then shuffle. + + reveal follows the card text ("reveal it" -> True; Adaman-style -> False). + """ + async def effect(ctx): + await _deal_printed(ctx) + picks = await ctx.search_deck( + predicate, count=count, minimum=minimum, + prompt=prompt or "Choose a card to put into your hand.", + ) + await ctx.put_in_hand(picks, reveal=reveal) + await ctx.shuffle_deck() + return effect + + +def search_to_bench(predicate=is_basic, count=1, then=None, prompt=""): + """Search the deck for Pokémon onto the Bench (capped by free bench + space, regi_gate shape), shuffle after; `then(ctx, benched)` runs last.""" + async def effect(ctx): + await _deal_printed(ctx) + space = BENCH_CAPACITY - len(ctx.my_bench()) + benched: List[CardEntity] = [] + take = min(count, space) + if take > 0: + picks = await ctx.search_deck( + predicate, count=take, minimum=0, + prompt=prompt or "Choose a Pokémon to put onto your Bench.", + ) + for card in picks: + if await ctx.bench_pokemon(card): + benched.append(card) + await ctx.shuffle_deck() + if then is not None and benched: + await then(ctx, benched) + return effect + + +# --- Energy search & attachment ---------------------------------------------- + +async def distribute_energy(ctx, cards, candidates): + """Attach each card to a chosen Pokémon ("in any way you like"); each card + may pick a different target. Returns the (card, target) pairs.""" + attached = [] + for card in cards: + target = await ctx.choose_pokemon( + candidates, f"Choose a Pokémon to attach {_card_label(card)} to" + ) + if target is None: + target = candidates[0] + await _attach_all(ctx, [card], target) + attached.append((card, target)) + return attached + + +def search_attach_energy(predicate=is_energy, count=1, to_self=False, + target_pred=None, distribute=True, shuffle=True, + prompt=""): + """Search the deck for up to `count` Energy and attach: to this Pokémon + (to_self), per-card free distribution, or all onto one chosen target.""" + async def effect(ctx): + await _deal_printed(ctx) + picks = await ctx.search_deck( + predicate, count=count, minimum=0, + prompt=prompt or f"Choose up to {count} Energy card(s) to attach.", + ) + if picks: + if to_self: + await _attach_all(ctx, picks, ctx.source) + else: + candidates = [p for p in ctx.my_pokemon_in_play() + if target_pred is None or target_pred(p)] + if candidates and distribute: + await distribute_energy(ctx, picks, candidates) + elif candidates: + target = await ctx.choose_pokemon( + candidates, "Choose a Pokémon to attach the Energy to" + ) + if target is not None: + await _attach_all(ctx, picks, target) + if shuffle: + await ctx.shuffle_deck() + return effect + + +def look_top_attach_energy(n, predicate=is_energy, rest="shuffle", + target_pred=None, distribute=True, minimum=0): + """Look at the top `n` deck cards and attach the Energy you find there; + rest: 'shuffle' the deck after, 'back' leaves the others on top in order.""" + async def effect(ctx): + await _deal_printed(ctx) + top = ctx.deck_top(n) + matches = [c for c in top if predicate(c)] + if matches: + picks = await ctx.choose_cards( + matches, len(matches), minimum=minimum, + prompt="Choose Energy cards to attach to your Pokémon.", + display_cards=top, + ) + candidates = [p for p in ctx.my_pokemon_in_play() + if target_pred is None or target_pred(p)] + if picks and candidates: + if distribute: + await distribute_energy(ctx, picks, candidates) + else: + target = await ctx.choose_pokemon( + candidates, "Choose a Pokémon to attach the Energy to" + ) + if target is not None: + await _attach_all(ctx, picks, target) + if rest == "shuffle": + await ctx.shuffle_deck() + return effect + + +# --- Discard-pile recursion ---------------------------------------------------- + +def attach_from_discard(predicate=is_energy, count=1, target="self", + minimum=1, then=None, prompt=""): + """Attach up to `count` matching discard-pile cards to one Pokémon: + 'self' = the acting Pokémon (abilities only), 'choice' = pick any of + yours, or a predicate narrowing the pickable targets. minimum=1 keeps the + activated public-zone pick mandatory; gate activation with a condition=.""" + async def effect(ctx): + await _deal_printed(ctx) + cards = [c for c in ctx.discard_pile() if predicate(c)] + if not cards: + return + picks = await ctx.choose_cards( + cards, count, minimum=minimum, + prompt=prompt or "Choose card(s) from your discard pile to attach", + ) + if not picks: + return + if target == "self": + holder: Optional[PokemonEntity] = ctx.source + else: + pred = None if target == "choice" else target + candidates = [p for p in ctx.my_pokemon_in_play() + if pred is None or pred(p)] + if not candidates: + return + holder = await ctx.choose_pokemon( + candidates, "Choose a Pokémon to attach the Energy to" + ) or candidates[0] + await _attach_all(ctx, picks, holder) + if then is not None: + await then(ctx, picks) + return effect + + +def recover_from_discard(predicate=None, count=1, minimum=1, reveal=False, + to="hand", prompt=""): + """Move up to `count` matching discard-pile cards to 'hand', + 'deck_shuffle', or 'deck_top' (ordered pick; the last pick ends on top). + minimum=1 discipline for activated public-zone picks.""" + async def effect(ctx): + await _deal_printed(ctx) + cards = [c for c in ctx.discard_pile() + if predicate is None or predicate(c)] + if not cards: + return + picks = await ctx.choose_cards( + cards, count, minimum=minimum, ordered=(to == "deck_top"), + prompt=prompt or "Choose card(s) from your discard pile", + ) + if not picks: + return + if to == "hand": + await ctx.put_in_hand(picks, reveal=reveal) + elif to == "deck_shuffle": + await ctx.shuffle_into_deck(picks) + elif to == "deck_top": + for card in picks: + await ctx.put_on_top_of_deck(card) + return effect + + +# --- Draw family --------------------------------------------------------------- + +def draw_attack(n): + """Printed damage (if an attack), then draw `n`; also fine as an ability.""" + async def effect(ctx): + await _deal_printed(ctx) + await ctx.draw_cards(n) + return effect + + +def conditional_draw(base, bonus, predicate): + """Draw `base`, or `base + bonus` when `predicate(ctx)` holds (Kabu shape).""" + async def effect(ctx): + await _deal_printed(ctx) + count = base + (bonus if predicate(ctx) else 0) + if count > 0: + await ctx.draw_cards(count) + return effect + + +def discard_then_draw(discard_count, draw_count, whole_hand=False, + optional=True, predicate=None, prompt=""): + """Discard from hand, then draw. optional=True gates the draw on any + discard ("If you do..."); whole_hand discards everything and always draws. + draw_count may be `int` or `(ctx, discarded) -> int` (Milo's 2-per).""" + async def effect(ctx): + await _deal_printed(ctx) + if whole_hand: + discarded = [c for c in ctx.hand() + if predicate is None or predicate(c)] + await ctx.discard_cards(discarded) + else: + discarded = await ctx.discard_from_hand( + discard_count, minimum=0 if optional else None, + predicate=predicate, + prompt=prompt or ("Choose up to %d card(s) to discard" % discard_count + if optional else + "Choose %d card(s) to discard" % discard_count), + ) + if not discarded: + return + n = draw_count(ctx, discarded) if callable(draw_count) else draw_count + if n > 0: + await ctx.draw_cards(n) + return effect + + +def draw_until_effect(n): + """Draw until the hand holds `n` cards (Dragon's Hoard shape).""" + async def effect(ctx): + await _deal_printed(ctx) + await ctx.draw_until(n) + return effect + + +def shuffle_hand_into_deck_draw(n, opponent_n=None): + """Shuffle your hand into your deck and draw `n` (Cynthia); with + opponent_n the opponent then does the same drawing that many (Judge).""" + async def effect(ctx): + await _deal_printed(ctx) + await ctx.shuffle_into_deck(ctx.hand(), ctx.player_id) + await ctx.draw_cards(n) + if opponent_n is not None: + await ctx.shuffle_into_deck(ctx.hand(ctx.opponent_id), ctx.opponent_id) + await ctx.draw_cards(opponent_n, ctx.opponent_id) + return effect + + +def look_at_top(n, take=1, predicate=None, rest="shuffle", minimum=None, + prompt=""): + """Look at the top `n` deck cards, put up to `take` matches into hand; + rest: 'shuffle' the deck, 'bottom' the others under it, 'back' leaves + them on top in order. minimum=None picks exactly `take` (or all if fewer).""" + async def effect(ctx): + await _deal_printed(ctx) + top = ctx.deck_top(n) + if not top: + return + candidates = [c for c in top if predicate is None or predicate(c)] + picks: List[CardEntity] = [] + if candidates and take > 0: + picks = await ctx.choose_cards( + candidates, take, minimum=minimum, + prompt=prompt or "Choose a card to put into your hand.", + display_cards=top if len(candidates) < len(top) else None, + ) + await ctx.put_in_hand(picks, reveal=False) + if rest == "shuffle": + await ctx.shuffle_deck() + elif rest == "bottom": + for card in top: + if card not in picks: + await ctx.put_on_bottom_of_deck(card) + return effect + + +# --- Heal family ----------------------------------------------------------------- + +async def _heal_scope_targets(ctx, scope, condition_cure=False): + """Targets for a heal scope; 'choice'/'bench_choice' pick one eligible.""" + if scope == "active": + active = ctx.my_active() + return [active] if active is not None else [] + if scope in ("each_own", "all_own"): + return ctx.my_pokemon_in_play() + pool = ctx.my_bench() if scope == "bench_choice" else ctx.my_pokemon_in_play() + eligible = [p for p in pool + if _is_damaged(ctx.board, p) + or (condition_cure and _has_conditions(p))] + if not eligible: + return [] + target = await ctx.choose_pokemon(eligible, "Choose a Pokémon to heal") + return [target] if target is not None else [] + + +def heal_attack(amount=None, all_damage=False, discard_energy=0, target="self"): + """Printed damage, optional self energy-discard cost, then heal this + Pokémon (`target='self'`) or your Active; all_damage heals everything.""" + async def effect(ctx): + await _deal_printed(ctx) + pokemon = ctx.attacker if target == "self" else ctx.my_active() + if pokemon is None: + return + if discard_energy > 0: + await ctx.discard_energy_from( + pokemon, discard_energy, + prompt=f"Discard {discard_energy} Energy", + ) + heal_amount = (ctx.max_hp(pokemon) - pokemon.get_attribute(AttrID.HP, 0)) \ + if all_damage else (amount or 0) + if heal_amount > 0: + await ctx.heal(heal_amount, pokemon) + return effect + + +def heal_targets(amount, scope="each_own"): + """Heal `amount` from the scope: 'each_own'/'all_own' (every one of + yours), 'active', 'bench_choice', or 'choice' (pick one damaged).""" + async def effect(ctx): + await _deal_printed(ctx) + for pokemon in await _heal_scope_targets(ctx, scope): + await ctx.heal(amount, pokemon) + return effect + + +def heal_item(amount, scope="choice", condition_cure=False): + """Trainer heal over a scope (see heal_targets); condition_cure also + removes Special Conditions (Pokémon Center Lady). Gate playability with + requires_damaged_pokemon().""" + async def effect(ctx): + for pokemon in await _heal_scope_targets(ctx, scope, condition_cure): + if amount > 0: + await ctx.heal(amount, pokemon) + if condition_cure: + await ctx.cure_all_conditions(pokemon) + return effect + + +def cure_conditions_effect(scope="active"): + """Remove all Special Conditions from the scope ('active', 'choice', + 'each_own'); conditions only, never attack locks.""" + async def effect(ctx): + await _deal_printed(ctx) + if scope == "choice": + afflicted = [p for p in ctx.my_pokemon_in_play() if _has_conditions(p)] + if not afflicted: + return + target = await ctx.choose_pokemon(afflicted, "Choose a Pokémon to recover") + targets = [target] if target is not None else [] + elif scope in ("each_own", "all_own"): + targets = [p for p in ctx.my_pokemon_in_play() if _has_conditions(p)] + else: + active = ctx.my_active() + targets = [active] if active is not None else [] + for pokemon in targets: + await ctx.cure_all_conditions(pokemon) + return effect + + +# --- Switching / gusting ----------------------------------------------------------- + +async def opponent_switches(ctx): + """The opponent switches their Active with a Benched Pokémon of THEIR + choice (escape_rope precedent); returns the new Active, or None.""" + bench = ctx.opponent_bench() + if not bench: + return None + target = await ctx.choose_pokemon( + bench, "Choose your new Active Pokémon", player_id=ctx.opponent_id + ) or bench[0] + await ctx.switch_active(ctx.opponent_id, target) + return target + + +def switch_self_attack(damage=None, optional=False, bench_predicate=None): + """Damage first (text order), then switch this Pokémon with a Benched one + of your choice; optional adds the "you may" dialog.""" + async def effect(ctx): + if damage is not None: + if damage > 0: + await ctx.deal_damage(damage) + else: + await _deal_printed(ctx) + bench = [p for p in ctx.my_bench() + if bench_predicate is None or bench_predicate(p)] + if not bench: + return + if optional and not await ctx.ask_yes_no( + "Switch this Pokémon with 1 of your Benched Pokémon?"): + return + target = await ctx.choose_pokemon(bench, "Choose your new Active Pokémon") + if target is not None: + await ctx.switch_active(ctx.player_id, target) + return effect + + +async def _gust(ctx, opponent_chooses=False): + """Switch one of the opponent's Benched Pokémon with their Active; + returns the new Active or None (no bench / effect-shielded Active).""" + old_active = ctx.opponent_active() + bench = ctx.opponent_bench() + if old_active is None or not bench: + return None + if ctx.effects_blocked(old_active): + return None + if opponent_chooses: + return await opponent_switches(ctx) + target = await ctx.choose_pokemon( + bench, "Choose the opponent's new Active Pokémon" + ) or bench[0] + await ctx.switch_active(ctx.opponent_id, target) + return target + + +def gust_attack(damage_to_new_active=0, damage_before=None, opponent_chooses=False): + """Gust: switch an opposing Benched Pokémon with their Active (YOU choose + unless the text says the opponent switches); optional damage before the + switch (printed by default) and onto the new Active after.""" + async def effect(ctx): + if damage_before is not None: + if damage_before > 0: + await ctx.deal_damage(damage_before) + else: + await _deal_printed(ctx) + new_active = await _gust(ctx, opponent_chooses) + if new_active is not None and damage_to_new_active > 0: + await ctx.deal_damage(damage_to_new_active, target=new_active) + return effect + + +def gust_then(then, opponent_chooses=False): + """Gust composite: switch, then `then(ctx, new_active)` (conditions etc.).""" + async def effect(ctx): + await _deal_printed(ctx) + new_active = await _gust(ctx, opponent_chooses) + if new_active is not None: + await then(ctx, new_active) + return effect + + +# --- Self-removal from play ----------------------------------------------------- + +_REMOVAL_PROMPTS = { + "hand": "Put this Pokémon and all attached cards into your hand?", + "deck": "Shuffle this Pokémon and all attached cards into your deck?", + "lost_zone": "Put this Pokémon in the Lost Zone?", +} + + +def remove_self_from_play(destination="hand", with_attachments="same", + optional=False, prompt=""): + """Remove the acting Pokémon from play to 'hand', 'deck' (shuffled) or + 'lost_zone'; with_attachments 'same' takes the whole stack along ("this + Pokémon and all attached cards"), 'discard' discards the attachments + (Scoop Up Net). Vacating the Active defers promotion (psychic_leap).""" + async def effect(ctx): + pokemon = ctx.source + await _deal_printed(ctx) + if optional and not await ctx.ask_yes_no( + prompt or _REMOVAL_PROMPTS.get(destination, "Remove this Pokémon from play?")): + return + was_active = pokemon is ctx.my_active() + if with_attachments == "discard": + await ctx.discard_cards([c for c in full_stack(pokemon) if c is not pokemon]) + stack = [pokemon] + else: + stack = full_stack(pokemon) + if destination == "hand": + await ctx.put_in_hand(stack, reveal=False) + elif destination == "deck": + await ctx.shuffle_into_deck(stack, ctx.player_id) + elif destination == "lost_zone": + await ctx.move_to_lost_zone(stack) + if was_active: + # Promotion must wait for the attack bracket to flush, or the client + # sees the new Active land while the old one still stands there. + async def _promote(): + if not await ctx.session._promote_new_active(ctx.player_id): + screen_name = ctx.session.players[ctx.player_id].screen_name + await ctx.session.end_game( + ctx.opponent_id, f"{screen_name} has no Pokémon left" + ) + + ctx.deferred_actions.append(_promote) + return effect + + +# --- Playability condition factories --------------------------------------------- +# Each returns a check accepting BOTH call shapes: trainers/energies call +# (board, player_id), abilities call (board, player_id, pokemon). + +def requires_discard(predicate=None, n=1): + """At least `n` matching cards sit in the player's discard pile.""" + def check(board, player_id, pokemon=None): + area = board.find_player_area(player_id, "discard") + cards = list(area.children) if area else [] + return sum(1 for c in cards if predicate is None or predicate(c)) >= n + return check + + +def requires_bench_space(n=1): + """At least `n` free bench slots.""" + def check(board, player_id, pokemon=None): + bench = board.find_player_area(player_id, "bench") + return bench is not None and BENCH_CAPACITY - len(bench.children) >= n + return check + + +def _side_player_ids(board, player_id, side) -> List[str]: + pids = [player_id] if side in ("mine", "any") else [] + if side in ("opponent", "any"): + opponent = _opponent_of(board, player_id) + if opponent: + pids.append(opponent) + return pids + + +def requires_in_play(predicate, side="mine"): + """A Pokémon matching `predicate` is in play on 'mine'/'opponent'/'any' side.""" + def check(board, player_id, pokemon=None): + return any(predicate(p) for pid in _side_player_ids(board, player_id, side) + for p in board.pokemon_in_play(pid)) + return check + + +def requires_hand(predicate=None, n=1, exclude_self=True): + """At least `n` matching cards in hand. exclude_self discounts the card + being played when it may be among the matches (predicate None always + matches it; otherwise only Trainer-card matches are suspect) -- pass + exclude_self=False when the card itself can never match `predicate`.""" + def check(board, player_id, pokemon=None): + hand = board.find_player_area(player_id, "hand") + matches = [c for c in (hand.children if hand else []) + if predicate is None or predicate(c)] + discount = 0 + if exclude_self and pokemon is None: + if predicate is None or any(is_trainer_card(c) for c in matches): + discount = 1 + return len(matches) - discount >= n + return check + + +def requires_damaged_pokemon(side="mine"): + """A damaged Pokémon is in play on 'mine'/'opponent'/'any' side.""" + def check(board, player_id, pokemon=None): + return any(_is_damaged(board, p) + for pid in _side_player_ids(board, player_id, side) + for p in board.pokemon_in_play(pid)) + return check diff --git a/spirit/game/data_utils.py b/spirit/game/data_utils.py index 66630fd..8274936 100644 --- a/spirit/game/data_utils.py +++ b/spirit/game/data_utils.py @@ -65,17 +65,72 @@ def prize_value(archetype_id: Optional[str]) -> int: ) +def _string_attr(definition: Optional["CardDefinition"], attr_id: AttrID) -> Optional[str]: + spec = definition.extra_attributes.get(str(attr_id.value)) if definition else None + return spec.get("value") if isinstance(spec, dict) else None + + +# EVOLUTION_LOGIC_NAME -> CardDefinition index, rebuilt lazily when new card +# scripts register (reprints share a name; any def in the line works). +_LOGIC_NAME_INDEX: Dict[str, "CardDefinition"] = {} +_LOGIC_NAME_INDEX_SIZE = -1 + + +def evolves_from_chain(archetype_id: Optional[str]) -> List[str]: + """EVOLUTION_LOGIC_NAME lineage below a card, direct pre-evolution first + (Blastoise -> ["Wartortle", "Squirtle"]), walking EVOLUTION_LOGIC_FROM.""" + global _LOGIC_NAME_INDEX_SIZE + if _LOGIC_NAME_INDEX_SIZE != len(CARD_DEFS_BY_GUID): + _LOGIC_NAME_INDEX.clear() + for d in CARD_DEFS_BY_GUID.values(): + logic_name = _string_attr(d, AttrID.EVOLUTION_LOGIC_NAME) + if logic_name: + _LOGIC_NAME_INDEX.setdefault(logic_name, d) + _LOGIC_NAME_INDEX_SIZE = len(CARD_DEFS_BY_GUID) + chain: List[str] = [] + definition = def_for(archetype_id) + for _ in range(8): # depth cap doubles as a cycle guard + from_name = _string_attr(definition, AttrID.EVOLUTION_LOGIC_FROM) + if not from_name or from_name in chain: + break + chain.append(from_name) + definition = _LOGIC_NAME_INDEX.get(from_name) + return chain + + +def evolves_from(archetype_id: Optional[str], base_logic_name: str) -> bool: + """Whether a card's evolution line includes `base_logic_name` anywhere + below it (Rare Candy: Stage2 over a Basic two steps down).""" + return base_logic_name in evolves_from_chain(archetype_id) + + class Triggers: """Events the game session fires scripted abilities on (Ability.trigger).""" ON_PLAY = "on_play" # the Pokemon is played from hand onto the bench ON_EVOLVE = "on_evolve" # the Pokemon evolves into this card ON_KNOCKED_OUT = "on_knocked_out" # this Pokemon is Knocked Out BETWEEN_TURNS = "between_turns" # fires on every Pokemon Checkup + # An opponent's attack damaged this Pokemon (Rocky Helmet); the trigger + # ctx carries damaged_by / damage_amount / pre_hit_hp. + ON_DAMAGED_BY_ATTACK = "on_damaged_by_attack" + # Either player manually attached an Energy from hand (Arctozolt); ctx + # carries attaching_player_id / attached_energy / energy_receiver. + ON_ENERGY_ATTACHED = "on_energy_attached" + # This Pokemon moved into the Active spot (Cinderace Libero); fires at + # most once per entity per turn. + ON_MOVE_TO_ACTIVE = "on_move_to_active" + # Another of the owner's Pokemon was Knocked Out (Exp. Share); fires + # BEFORE the KO'd stack moves (energies still attached); ctx carries + # ko_pokemon / ko_from_attack / ko_attacker. + ON_ALLY_KNOCKED_OUT = "on_ally_knocked_out" class Activations: """How a non-triggered ability is used (Ability.activation).""" ONCE_PER_TURN = "once_per_turn" # offered as a selectable action, once per turn + # Usable any number of times per turn; MUST pair with a condition= that + # gates no-op uses, or the offer never disappears. + UNLIMITED = "unlimited" # PIE_ABILITIES abilityType doubles as the JsonFx type hint: it must be a # PieAbilityDescription subclass CLASS NAME (DwdModelAnalyzer.TypeHintedClasses @@ -111,9 +166,14 @@ class Ability: vstar: bool = False, passive: Optional[Any] = None, condition: Optional[Callable] = None, - shared_once_per_turn: Optional[str] = None + shared_once_per_turn: Optional[str] = None, + ends_turn: bool = False, + usable_from: Optional[str] = None ): self.title = title + # 'hand' | 'discard': offered as an OutOfPlay action while the card + # sits in that zone instead of in play (Beedrill, Luxio). + self.usable_from = usable_from self.game_text = game_text self.ability_type = ability_type self.effect = effect @@ -121,9 +181,11 @@ class Ability: # lock shared by name across every copy in play (Dark Asset). # None = the plain per-entity limit. self.shared_once_per_turn = shared_once_per_turn - # A Triggers value: the session runs `effect` on that event instead of - # offering the ability as a selectable action. + # A Triggers value (or a tuple/list of them): the session runs `effect` + # on that event instead of offering the ability as a selectable action. self.trigger = trigger + # "If you use this Ability, your turn ends" (Rotom V Instant Charge). + self.ends_turn = ends_turn # An Activations value: the ability is offered as a selectable action. self.activation = activation # VSTAR Powers are usable once per game and flip the playmat marker. @@ -142,6 +204,15 @@ class Ability: self.effect = fn return fn + def has_trigger(self, trigger: str) -> bool: + """Whether this ability fires on `trigger` (self.trigger may be a + single Triggers value or a tuple/list of them).""" + if self.trigger is None: + return False + if isinstance(self.trigger, (tuple, list, set, frozenset)): + return trigger in self.trigger + return self.trigger == trigger + def to_dict(self) -> dict: d = { "abilityType": ABILITY_TYPE_HINTS.get(self.ability_type, "PokeAbility"), @@ -169,7 +240,8 @@ class Attack(Ability): effect: Optional[Any] = None, vstar: bool = False, locks_next_turn: bool = False, - condition: Optional[Callable] = None + condition: Optional[Callable] = None, + usable_first_turn: bool = False ): super().__init__(title, game_text, ability_type, effect, vstar=vstar, condition=condition) @@ -178,6 +250,8 @@ class Attack(Ability): self.damage_operator = damage_operator # "During your next turn, this Pokemon can't use ." self.locks_next_turn = locks_next_turn + # Exempt from the "going first can't attack on turn 1" rule (Indeedee). + self.usable_first_turn = usable_first_turn def to_dict(self) -> dict: d = super().to_dict() @@ -278,10 +352,17 @@ class PokemonCardDef(CardDefinition): display_name: Optional[str] = None, searchable_by: Optional[List[str]] = None, subtypes: Optional[List[str]] = None, - attributes: Optional[dict] = None + attributes: Optional[dict] = None, + passive: Optional[Any] = None, + unplayable_from_hand: bool = False ): super().__init__(guid, key, name, collector_number, set_code, rarity, display_name, searchable_by, subtypes, attributes) - + # Card-level continuous effect while this Pokemon is top-level in play + # (attack-rules passives, e.g. Swanna); distinct from Ability(passive=). + self.passive = passive + # Shedinja: never offered as a hand bench-play (enters play by effect). + self.unplayable_from_hand = unplayable_from_hand + # Add Pokemon-specific defaults to extra_attributes self.extra_attributes.update({ str(AttrID.CARD_TYPE.value): {"type": "int", "value": CardType.POKEMON.value}, @@ -407,12 +488,15 @@ class PokemonToolCardDef(TrainerCardDef): """`passive` is the tool's continuous effect while attached (a passives.Passive); `effect` stays unused for plain stat tools. `granted_abilities` are Abilities the tool grants its holder while - attached (Forest Seal Stone).""" + attached (Forest Seal Stone). `attach_to(pokemon_entity) -> bool` + restricts legal attach targets (Hero's Medal).""" def __init__(self, passive: Optional[Any] = None, - granted_abilities: Optional[List[Ability]] = None, **kwargs): + granted_abilities: Optional[List[Ability]] = None, + attach_to: Optional[Callable] = None, **kwargs): kwargs['trainer_type'] = TrainerType.POKEMON_TOOL super().__init__(**kwargs) self.passive = passive + self.attach_to = attach_to self.granted_abilities: List[Ability] = granted_abilities or [] for idx, a in enumerate(self.granted_abilities): if not a.ability_id: diff --git a/spirit/game/models/board.py b/spirit/game/models/board.py index 7be593f..5f10be2 100644 --- a/spirit/game/models/board.py +++ b/spirit/game/models/board.py @@ -279,6 +279,9 @@ class BoardState: # Set by GameSession once both exist; damage/condition lookups read it # defensively (getattr(board, "turn_state", None)) for bare test boards. self.turn_state = None + # Effect-granted TempPassive entries (passives.py); pruned by + # TurnState.begin_turn expiry and GameSession.clear_pokemon_effects. + self.temporary_passives: List[Any] = [] self._initialize_board() @@ -455,13 +458,18 @@ class BoardState: ) def basic_pokemon_in_hand(self, player_id: str) -> List['PokemonEntity']: - """All Basic Pokemon entities currently in the player's hand.""" + """All Basic Pokemon entities currently in the player's hand that may + be played from it (Shedinja's unplayable_from_hand is excluded, so it + neither satisfies the mulligan check nor offers as a placement).""" + from spirit.game.data_utils import def_for # circular-import guard hand_area = self.find_player_area(player_id, "hand") if not hand_area: return [] basics: List[PokemonEntity] = [] for c in hand_area.children: if isinstance(c, PokemonEntity) and self._is_basic_pokemon(c): + if getattr(def_for(c.archetype_id), "unplayable_from_hand", False): + continue basics.append(c) return basics diff --git a/spirit/game/scripts/cards/__init__.py b/spirit/game/scripts/cards/__init__.py index 9294b5e..c5882c3 100644 --- a/spirit/game/scripts/cards/__init__.py +++ b/spirit/game/scripts/cards/__init__.py @@ -13,6 +13,8 @@ class ScriptLoader: self.cards: List[Card] = [] self.cards_by_guid: Dict[str, Card] = {} self.cards_by_key: Dict[str, Card] = {} + # script filename stem (e.g. "Watchog_79") -> archetype GUID + self.cards_by_stem: Dict[str, str] = {} def load_all(self, force=False): """Loads all card scripts once; cached thereafter unless force=True. @@ -25,6 +27,7 @@ class ScriptLoader: self.cards = [] self.cards_by_guid = {} self.cards_by_key = {} + self.cards_by_stem = {} logging.info(f"[Scripts] Loading card scripts from {self.scripts_dir}...") @@ -75,6 +78,7 @@ class ScriptLoader: self.cards.append(card_obj) self.cards_by_guid[guid] = card_obj self.cards_by_key[key] = card_obj + self.cards_by_stem[os.path.splitext(os.path.basename(file_path))[0]] = guid else: logging.warning(f"[Scripts] Script {file_path} does not define a 'card' object.") diff --git a/spirit/game/session/effects.py b/spirit/game/session/effects.py index fa23b51..e94e197 100644 --- a/spirit/game/session/effects.py +++ b/spirit/game/session/effects.py @@ -7,7 +7,6 @@ play's sequence brackets. Interactive primitives (choosers, dialogs) resolve inline before any choreography is flushed. """ -import asyncio import logging import random from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, cast @@ -25,20 +24,29 @@ from spirit.game.attributes import ( TrainerType, ) from spirit.game.data_utils import ( + ABILITIES_BY_ID, TRAINER_EFFECTS_BY_GUID, Ability, + Triggers, def_for, has_rule_box, unimplemented, ) from spirit.game.models.board import BoardEntity, CardEntity, EnergyEntity, PokemonEntity from spirit.network.message_names import OutboundMsg -from .constants import BENCH_CAPACITY, PROMPT_NO, PROMPT_YES +from .constants import PROMPT_NO, PROMPT_YES from .passives import ( + TempPassive, + ability_effects_blocked, ability_locked, attack_effects_blocked, + carrier_pokemon, compute_damage, + conditions_blocked, + discard_blocked, + effective_bench_capacity, effective_max_hp, + healing_blocked, ) # CakeAttackEffect's damageType is a string array of client type names. @@ -95,6 +103,9 @@ class EffectContext: # Async callables run AFTER the choreography flushes (promotions and # anything else that must not interleave with the pending brackets). self.deferred_actions: List[Callable[[], Any]] = [] + # Set by the effect (or auto-set from Ability.ends_turn) to end the + # acting player's turn once this effect resolves (Rotom Bike). + self.ends_turn: bool = False # Attack titles already resolving in this attack (copy-loop guard). self._copy_chain: List[str] = [] self._messages: List[Tuple[Optional[str], Dict[str, Any], Optional[str]]] = [] @@ -102,6 +113,19 @@ class EffectContext: # True iff the causing damage was an opposing Pokemon's attack. self.ko_from_attack: bool = False self.ko_attacker: Optional[PokemonEntity] = None + # ON_ALLY_KNOCKED_OUT: the KO'd ally (still on board, energies attached). + self.ko_pokemon: Optional[PokemonEntity] = None + # target entity_id -> (dealt, pre_hit_hp); first attack hit wins. + # Feeds ON_DAMAGED_BY_ATTACK and full-HP-at-KO checks. + self.attack_damage: Dict[str, Tuple[int, int]] = {} + # ON_DAMAGED_BY_ATTACK trigger inputs (set via ctx_setup). + self.damaged_by: Optional[PokemonEntity] = None + self.damage_amount: int = 0 + self.pre_hit_hp: int = 0 + # ON_ENERGY_ATTACHED trigger inputs (set via ctx_setup). + self.attaching_player_id: Optional[str] = None + self.attached_energy: Optional[CardEntity] = None + self.energy_receiver: Optional[PokemonEntity] = None # ------------------------------------------------------------------ # Game state accessors @@ -192,12 +216,21 @@ class EffectContext: AbilityTypes.ATTACK, AbilityTypes.NON_DAMAGING_ATTACK ) + def is_ability_effect(self) -> bool: + """Whether this ctx resolves a Pokemon Ability (not an attack/trainer).""" + return self.ability is not None and not self.is_attack_effect() + def effects_blocked(self, target: PokemonEntity) -> bool: - """Whether an opposing attack EFFECT on `target` is shielded (e.g. - Unfazed Fat). Only attack effects against the other side count.""" - if not self.is_attack_effect() or target.owning_player_id == self.player_id: + """Whether an opposing attack/Ability EFFECT on `target` is shielded + (Unfazed Fat / Corviknight VMAX). Only effects against the other + side count; trainer effects are never shielded here.""" + if target.owning_player_id == self.player_id: return False - return attack_effects_blocked(self.board, target) + if self.is_attack_effect(): + return attack_effects_blocked(self.board, target) + if self.is_ability_effect(): + return ability_effects_blocked(self.board, target) + return False # ------------------------------------------------------------------ # Damage / HP primitives @@ -211,6 +244,7 @@ class EffectContext: is_attack: Optional[bool] = None, ignore_target_effects: bool = False, ignore_weakness: bool = False, + ignore_resistance: bool = False, as_counters: bool = False, ) -> int: """Damages a Pokemon (default: the attack's printed damage onto the @@ -219,7 +253,8 @@ class EffectContext: Weakness/Resistance apply only to the opponent's Active by default (bench damage in the TCG is unmodified unless the card says otherwise). ignore_target_effects (Max Miracle) skips passives riding the target. - ignore_weakness (Spit Innocently) skips only the Weakness stage. + ignore_weakness (Spit Innocently) skips only the Weakness stage; + ignore_resistance (Buster Swing) skips only the Resistance stage. as_counters plays the counter-drop FX (PlaceDamageEffect, m.p) instead of the attack lunge (CakeAttackEffect) -- "put N damage counters" effects (Lost Mine, Glistening Droplets). @@ -250,6 +285,8 @@ class EffectContext: is_attack=is_attack, apply_modifiers=apply_modifiers, ignore_target_effects=ignore_target_effects, ignore_weakness=ignore_weakness, + ignore_resistance=ignore_resistance, + attack_title=self.ability.title if self.ability else None, ) if calc.prevented: logging.info( @@ -297,6 +334,13 @@ class EffectContext: if target.owning_player_id != self.attacker.owning_player_id: if not as_counters: self._dealt_opponent_damage = True + # ON_DAMAGED_BY_ATTACK ledger: attack hits only, first-write + # wins so the pre-hit HP is the true pre-attack value. + if dealt > 0 and is_attack: + self.attack_damage.setdefault(target.entity_id, (dealt, current)) + if dealt > 0: + taken = self.session.turn_state.damage_taken + taken[target.entity_id] = taken.get(target.entity_id, 0) + dealt self.session.stat_add(self.player_id, "damagedealt", dealt) self.session.credit_card_damage(self.player_id, self.attacker, dealt) if is_attack: @@ -328,11 +372,18 @@ class EffectContext: target = target if target is not None else self.my_active() if target is None or amount <= 0: return 0 + if healing_blocked(self.board, target): + logging.info( + f"[Effects {self.game_id}] Healing on {target.entity_id} " + f"prevented by a passive effect." + ) + return 0 current = target.get_attribute(AttrID.HP, 0) healed = min(self.max_hp(target), current + amount) - current if healed <= 0: return 0 target.set_attribute(AttrID.HP, current + healed) + self.session.turn_state.healed_entities.add(target.entity_id) self.session.stat_add(self.player_id, "damagehealed", healed) self._queue_hp_update(target) return healed @@ -361,6 +412,12 @@ class EffectContext: f"blocked by an effect shield." ) return False + if conditions_blocked(self.board, target, condition): + logging.info( + f"[Effects {self.game_id}] {condition.name} on {target.entity_id} " + f"blocked by a condition-immunity passive." + ) + return False name = CLIENT_SPECIAL_CONDITION_NAMES[condition] conditions = list(target.get_attribute(AttrID.SPECIAL_CONDITIONS) or []) if condition in _MUTUALLY_EXCLUSIVE: @@ -410,10 +467,120 @@ class EffectContext: ) return True + async def cure_condition(self, target: Optional[PokemonEntity], + condition: SpecialConditions) -> bool: + """Removes exactly ONE Special Condition (Clefable's Moonlit Cure); + other conditions stay. Returns True if it was present.""" + if target is None: + return False + name = CLIENT_SPECIAL_CONDITION_NAMES[condition] + if name not in (target.get_attribute(AttrID.SPECIAL_CONDITIONS) or []): + return False + msg = self.session._remove_single_condition(target, condition) + # Executor ctor (M.t) indexes the bracket's data effects with "Target". + self._queue( + self.session._entity_id_data_effect_msg("Target", target.entity_id), + bracket=GameSequence.REMOVE_SPECIAL_CONDITION.value, + ) + self._queue(msg, bracket=GameSequence.REMOVE_SPECIAL_CONDITION.value) + return True + def add_turn_damage_modifier(self, mod) -> None: - """Registers a TurnDamageModifier for the rest of the current turn.""" + """Registers a TurnDamageModifier (expires_after_turn None = this turn).""" self.session.turn_state.damage_modifiers.append(mod) + def add_temporary_passive(self, target, passive, + expires_after_turn: Optional[int] = None) -> None: + """Attaches an effect-granted passive to `target` (expires_after_turn + None = until it leaves the Active spot / play).""" + self.board.temporary_passives.append( + TempPassive(passive, target.entity_id, expires_after_turn) + ) + + def add_passive_through_opponents_turn(self, target, passive) -> None: + """"During your opponent's next turn ..." lifetime.""" + self.add_temporary_passive( + target, passive, self.session.turn_state.turn_number + 1 + ) + + def add_passive_through_own_next_turn(self, target, passive) -> None: + """"During your next turn ..." lifetime (spans the opponent's turn too).""" + self.add_temporary_passive( + target, passive, self.session.turn_state.turn_number + 2 + ) + + def lock_retreat(self, target: PokemonEntity, + through_turn: Optional[int] = None) -> None: + """"The Defending Pokemon can't retreat during your opponent's next + turn" (default); pass legal_actions.LOCK_UNTIL_LEAVES_ACTIVE to hold + the lock until it leaves the Active spot.""" + self.session.turn_state.lock_retreat(target.entity_id, through_turn) + + def lock_plays(self, player_id: str, predicate: Callable[[CardEntity], bool], + through_turn: Optional[int] = None) -> None: + """" can't play " (default: through + their next turn).""" + self.session.turn_state.lock_plays(player_id, predicate, through_turn) + + def restrict_attachments(self, target: PokemonEntity, + through_turn: Optional[int] = None) -> None: + """"Energy can't be attached to the Defending Pokemon" (default: + through the opponent's next turn); manual attach offers exclude it.""" + self.session.turn_state.restrict_attachments(target.entity_id, through_turn) + + # ------------------------------------------------------------------ + # Turn-history accessors (two turns kept, rotated at begin_turn) + # ------------------------------------------------------------------ + + def kos_suffered_last_turn(self, player_id: Optional[str] = None) -> int: + """How many of a player's Pokemon were KO'd by attacks last turn.""" + ledger = self.session.turn_state.kos_by_attack_last_turn + return len(ledger.get(player_id or self.player_id, [])) + + def attack_used_last_turn(self, title: Optional[str] = None, + entity=None) -> bool: + """Whether an attack (optionally by title and/or entity) was declared last turn.""" + entity_id = getattr(entity, "entity_id", entity) + for used_id, _archetype, used_title in self.session.turn_state.attacks_used_last_turn: + if title is not None and used_title != title: + continue + if entity_id is not None and used_id != entity_id: + continue + return True + return False + + def damage_taken_last_turn(self, pokemon) -> int: + return self.session.turn_state.damage_taken_last_turn.get( + pokemon.entity_id, 0 + ) + + def entered_active_this_turn(self, pokemon) -> bool: + state = self.session.turn_state + return state.became_active_turn.get(pokemon.entity_id) == state.turn_number + + def played_trainer_this_turn(self, name_or_pred=None) -> int: + """Count of trainers played this turn; filter by display name (str) or + a predicate over the (archetype_id, name, trainer_type) record.""" + count = 0 + for record in self.session.turn_state.trainers_played: + if name_or_pred is None: + count += 1 + elif callable(name_or_pred): + count += 1 if name_or_pred(record) else 0 + elif record[1] == name_or_pred: + count += 1 + return count + + def supporters_played_this_turn(self) -> int: + return self.played_trainer_this_turn( + lambda r: r[2] == TrainerType.SUPPORTER.value + ) + + def items_played_this_turn(self) -> int: + return self.played_trainer_this_turn( + lambda r: r[2] == TrainerType.ITEM.value + ) + async def add_stat_visualization( self, pokemon: PokemonEntity, arrow: str, display_type: str, card_text: Optional[str] = None, @@ -463,15 +630,13 @@ class EffectContext: self.session.turn_state.lock_attack(self.attacker.entity_id, ability.ability_id) return True - async def flip_coins(self, count: int, title: str = "") -> List[bool]: - """Flips `count` coins for a card effect ("Flip 2 coins..."); returns - the results, True = heads.""" - if count <= 0: - return [] - results = [random.choice([0, 1]) for _ in range(count)] + async def _queue_coin_results(self, results: List[int], title: str): + """Sends ONE MultipleCoinFlipWithContextEffect for a pre-rolled run + (0 = heads); queued inline in attack/ability context, sent as its own + PokeAbility bracket + pacing pause in trainer context.""" heads = results.count(0) self.session.stat_add(self.player_id, "headsflipped", heads) - self.session.stat_add(self.player_id, "tailsflipped", count - heads) + self.session.stat_add(self.player_id, "tailsflipped", len(results) - heads) msg = self.session._build_msg( OutboundMsg.MULTIPLE_COIN_FLIP_WITH_CONTEXT_EFFECT.value, { @@ -493,9 +658,26 @@ class EffectContext: list(self.session.players.values()), GameSequence.POKE_ABILITY, [msg], ) - await asyncio.sleep(2.5) + await self.session.choreo_pause(2.5) + + async def flip_coins(self, count: int, title: str = "") -> List[bool]: + """Flips `count` coins for a card effect ("Flip 2 coins..."); returns + the results, True = heads.""" + if count <= 0: + return [] + results = [random.choice([0, 1]) for _ in range(count)] + await self._queue_coin_results(results, title) return [r == 0 for r in results] + async def flip_until_tails(self, title: str = "") -> int: + """"Flip a coin until you get tails": one coin screen shows the whole + run; returns the heads count.""" + results = [random.choice([0, 1])] + while results[-1] == 0: + results.append(random.choice([0, 1])) + await self._queue_coin_results(results, title) + return results.count(0) + async def place_damage_counters( self, count: int, candidates: Optional[List[PokemonEntity]] = None ) -> None: @@ -520,6 +702,68 @@ class EffectContext: amount=counters * 10, target=by_id[entity_id], as_counters=True, ) + async def set_damage_counters(self, target: Optional[PokemonEntity], + counters: int) -> None: + """Sets a Pokemon's damage to exactly `counters` (HP = max - 10n, + Claydol-style); a result of 0 HP enqueues the knockout.""" + if target is None: + return + new_hp = max(0, self.max_hp(target) - counters * 10) + target.set_attribute(AttrID.HP, new_hp) + self._queue_hp_update(target) + if new_hp <= 0 and target not in self.knockouts: + self.knockouts.append(target) + + async def move_damage_counters( + self, + source: Optional[PokemonEntity], + dest_or_targets, + max_count: Optional[int] = None, + prompt: str = "Place the moved damage counters", + ) -> int: + """Moves damage counters off `source` (heal + raw counter placement, + atomic), clamped to its actual damage; returns counters moved. + + A single shielded destination fizzles the WHOLE move; in a + multi-target distribution shielded picks stay legal but their + counters are prevented (wasted), per the Unfazed Fat ruling. + """ + if source is None: + return 0 + damage = max(0, self.max_hp(source) - source.get_attribute(AttrID.HP, 0)) + available = damage // 10 + count = available if max_count is None else min(available, max_count) + if count <= 0: + return 0 + if isinstance(dest_or_targets, PokemonEntity): + if self.effects_blocked(dest_or_targets): + return 0 + pool = [dest_or_targets] + placement = {dest_or_targets.entity_id: count} + else: + pool = [p for p in dest_or_targets if p is not source] + if not pool: + return 0 + placement = await self.session.prompt_damage_counter_placement( + self.player_id, self.source.entity_id, pool, count, prompt=prompt, + ) + total = sum(v for v in placement.values() if v > 0) + if total <= 0: + return 0 + healed = await self.heal(total * 10, source) + if healed <= 0: + return 0 + remaining = healed // 10 + by_id = {p.entity_id: p for p in pool} + for entity_id, n in placement.items(): + if n <= 0 or entity_id not in by_id or remaining <= 0: + continue + n = min(n, remaining) + remaining -= n + await self.deal_damage(amount=n * 10, target=by_id[entity_id], + as_counters=True) + return healed // 10 + # ------------------------------------------------------------------ # Interactive primitives (resolve inline, before choreography) # ------------------------------------------------------------------ @@ -780,6 +1024,57 @@ class EffectContext: self._queue(move, viewer_id=owner) self._queue(move, viewer_id=opponent) + async def reveal_cards(self, cards: Sequence[CardEntity], + to_player: Optional[str] = None) -> None: + """Reveals cards where they SIT ("reveal the top card of your deck"): + each blind viewer gets the intro (SerialSequence) then a + [RevealCardToAllEffect(Return=true), same-position move] GroupedMove + pair -- k.z presents the card large-center and flies it home. + + to_player=None reveals to every viewer the card is currently hidden + from (own-hand reveals reach the opponent; deck cards reach both). + """ + for card in cards: + parent = getattr(card, "parent", None) + if parent is None: + continue + try: + position = parent.children.index(card) + except ValueError: + position = 0 + if to_player is not None: + viewers = [to_player] + else: + viewers = [pid for pid in self.session.players + if card.is_hidden_from(pid)] + if not viewers: + continue + intro = self.session._entity_introduced_msg(card) + move = self.session._entity_moved_msg(card.entity_id, parent.entity_id, position) + for vid in viewers: + self._queue(intro, viewer_id=vid, + bracket=GameSequence.SERIAL_SEQUENCE.value) + self._queue(self.session._reveal_card_msg(card.entity_id, True), + viewer_id=vid, bracket=GameSequence.GROUPED_MOVE.value) + self._queue(move, viewer_id=vid, + bracket=GameSequence.GROUPED_MOVE.value) + + async def reveal_hand(self, of_player: Optional[str] = None, + to_player: Optional[str] = None) -> List[CardEntity]: + """View-only reveal browser over a player's whole hand ("your opponent + reveals their hand"); nothing is selectable. Returns the hand cards + so callers can count matches. AI viewers skip the browser.""" + owner = of_player or self.player_id + viewer = to_player or self.session._opponent_id(owner) + cards = self.hand(owner) + if not cards: + return [] + await self.session.prompt_view_cards( + viewer, self.source.entity_id, cards, + prompt="Your opponent's hand" if viewer != owner else "Revealed cards", + ) + return cards + async def present_card_choice( self, card: CardEntity, prompt: str, buttons: List[str], player_id: Optional[str] = None, @@ -832,6 +1127,15 @@ class EffectContext: pile = self.board.find_player_area(owner, area_name) if not pile: continue + # Opponent-caused discards can be shielded (Bibarel PGO/Greedent); + # own discards (costs) always resolve. + if area_name == "discard" and owner != self.player_id \ + and discard_blocked(self.board, card): + logging.info( + f"[Effects {self.game_id}] Discard of {card.entity_id} " + f"blocked by a passive." + ) + continue holder = self._tool_holder_before_move(card) source = getattr(card, "parent", None) # deck/prizes are face-down to the owner too, so a card from there @@ -1000,6 +1304,35 @@ class EffectContext: ), bracket=bracket) return len(cards) + async def reorder_deck_top(self, count: int, + player_id: Optional[str] = None, + prompt: str = "Rearrange the cards on top of your deck", + ) -> List[CardEntity]: + """Looks at the top `count` deck cards and puts them back in any + order (ordered browser, owner-only; the opponent learns nothing -- + hidden-zone browser cards re-hide on close and no moves are sent). + Returns the new top order (topmost first).""" + pid = player_id or self.player_id + top = self.deck_top(count, pid) + if len(top) <= 1: + return top + picked_ids = await self.session.prompt_card_chooser( + pid, self.source.entity_id, top, len(top), minimum=len(top), + prompt=prompt, ordered=True, + ) + by_id = {c.entity_id: c for c in top} + order = [by_id[i] for i in picked_ids if i in by_id] + for card in top: + if card not in order: + order.append(card) + deck = self.board.find_player_area(pid, "deck") + for card in order: + deck.children.remove(card) + # First pick = new top; top of the deck is the LAST child. + for card in reversed(order): + deck.children.append(card) + return order + async def put_on_top_of_deck(self, card: CardEntity) -> bool: """Puts a card on top of its owner's deck.""" owner = card.owning_player_id or self.player_id @@ -1037,7 +1370,7 @@ class EffectContext: """ owner = card.owning_player_id or self.player_id bench = self.board.find_player_area(owner, "bench") - if not bench or len(bench.children) >= BENCH_CAPACITY: + if not bench or len(bench.children) >= effective_bench_capacity(self.board, owner): return False self._note_visual_source(card) # Lowest free SLOT (client stamp), not list length -- gaps left by @@ -1050,17 +1383,120 @@ class EffectContext: self._queue_intro_and_move(card, bench.entity_id, position) return True - async def attach_energy(self, energy: CardEntity, pokemon: PokemonEntity) -> bool: + async def evolve_pokemon(self, target: PokemonEntity, + evolution_card: CardEntity) -> bool: + """Effect-driven evolution (Rare Candy): bypasses the may-evolve turn + rules entirely; handles deck-sourced evolution cards (intro to both + viewers before the Evolve bracket per the wrap-FX rule). Flushes any + queued choreography first so the brackets land in order.""" + if target is None or evolution_card is None: + return False + area_name = evolution_card._containing_area_name() \ + if isinstance(evolution_card, CardEntity) else None + from_hidden = area_name in CardEntity.HIDDEN_FROM_OWNER_AREAS + owner = evolution_card.owning_player_id or self.player_id + await self.flush_choreography() + return await self.session.perform_evolution( + owner, evolution_card, target, from_zone_intro=from_hidden + ) + + async def attach_energy(self, energy: CardEntity, pokemon: PokemonEntity, + counts_as_attachment: bool = False) -> bool: """Attaches an energy card from any zone underneath a Pokemon - (effect attachments don't consume the once-per-turn manual attach).""" + (effect attachments don't consume the once-per-turn manual attach). + + counts_as_attachment=True additionally fires ON_ENERGY_ATTACHED + observers (deferred until the choreography flushes); most effect + attaches are NOT "attaching from hand" and leave it False. + """ if energy is None or pokemon is None: return False position = len(pokemon.children) if not self.board.attach_card(energy.entity_id, pokemon.entity_id): return False self._queue_intro_and_move(energy, pokemon.entity_id, position) + if counts_as_attachment: + self.deferred_actions.append( + lambda e=energy, p=pokemon: self.session.fire_energy_attached_triggers( + self.player_id, e, p)) return True + async def move_energy(self, energy: CardEntity, to_pokemon: PokemonEntity) -> bool: + """Moves an attached Energy onto another in-play Pokemon: no intro + (attached cards are already public), the GroupedMove plays the attach + FX; max-HP bonuses shift with the card, damage taken stays constant.""" + if energy is None or to_pokemon is None: + return False + old_holder = carrier_pokemon(energy) + if old_holder is to_pokemon: + return False + granted_holder = self._tool_holder_before_move(energy) + max_before_old = effective_max_hp(self.board, old_holder) \ + if old_holder is not None else 0 + max_before_new = effective_max_hp(self.board, to_pokemon) + position = len(to_pokemon.children) + if not self.board.attach_card(energy.entity_id, to_pokemon.entity_id): + return False + self._queue( + self.session._entity_moved_msg(energy.entity_id, to_pokemon.entity_id, position), + bracket=GameSequence.GROUPED_MOVE.value, + ) + if granted_holder is not None: + await self.session.refresh_granted_abilities(granted_holder) + if old_holder is not None: + self._shift_max_hp(old_holder, max_before_old) + self._shift_max_hp(to_pokemon, max_before_new) + return True + + def _shift_max_hp(self, pokemon: PokemonEntity, max_before: int) -> None: + """Keeps damage-taken constant when an attachment's max-HP bonus + arrives/leaves mid-effect; queues the HP update, enqueues a KO at 0.""" + max_after = effective_max_hp(self.board, pokemon) + delta = max_after - max_before + if delta == 0: + return + current = max(0, pokemon.get_attribute(AttrID.HP, 0) + delta) + pokemon.set_attribute(AttrID.HP, current) + self._queue_hp_update(pokemon) + if current <= 0 and pokemon not in self.knockouts: + self.knockouts.append(pokemon) + + async def move_energy_freely( + self, + sources: Sequence[PokemonEntity], + dest_candidates: Sequence[PokemonEntity], + predicate: Optional[Callable[[CardEntity], bool]] = None, + max_count: Optional[int] = None, + prompt: str = "Choose an Energy to move", + ) -> List[Tuple[CardEntity, PokemonEntity]]: + """"Move any amount of Energy ... in any way you like": repeats + [pick an attached energy pip, minimum 0 = stop] -> [pick its + destination] until the player declines or the pool is exhausted. + Each energy moves at most once. Returns the (energy, dest) moves.""" + moved: List[Tuple[CardEntity, PokemonEntity]] = [] + source_list = list(sources) + while max_count is None or len(moved) < max_count: + moved_ids = {e.entity_id for e, _ in moved} + pool = [e for p in source_list for e in self.attached_energies(p) + if e.entity_id not in moved_ids + and (predicate is None or predicate(e))] + if not pool: + break + picked = await self.choose_cards(pool, 1, minimum=0, prompt=prompt) + if not picked: + break + energy = picked[0] + holder = carrier_pokemon(energy) + dests = [d for d in dest_candidates if d is not holder] + if not dests: + break + dest = await self.choose_pokemon( + dests, "Choose a Pokémon to move the Energy to") + if dest is None or not await self.move_energy(energy, dest): + break + moved.append((energy, dest)) + return moved + async def switch_active(self, player_id: str, new_active: PokemonEntity) -> bool: """Swaps a player's Active with the given benched Pokemon (gust or self-switch). Special Conditions on the leaving Active are cured.""" @@ -1092,6 +1528,8 @@ class EffectContext: slot = board.bench_slot_of(new_active) board.move_card(new_active.entity_id, active_area.entity_id) board.move_card(old_active.entity_id, bench_area.entity_id, slot) + self.session.turn_state.became_active_turn[new_active.entity_id] = \ + self.session.turn_state.turn_number # Retreat (N.P) is the only executor that flies both swap moves # concurrently; it requires the Retreating/NewActive data effects. # NEVER ParallelSequence (r.M's command list is null in this build). @@ -1112,6 +1550,9 @@ class EffectContext: self.session._entity_moved_msg(old_active.entity_id, bench_area.entity_id, slot), bracket=bracket, ) + # ON_MOVE_TO_ACTIVE fires after the swap choreography flushes. + self.deferred_actions.append( + lambda p=new_active: self.session.fire_move_to_active_triggers(p)) return True async def flush_choreography(self): @@ -1121,6 +1562,24 @@ class EffectContext: await self.session._flush_effect_runs(self) self._messages.clear() + async def take_prizes(self, count: int, player_id: Optional[str] = None) -> None: + """Takes prize cards outside the KO flow (Slowbro PGO): flushes the + queued choreography first so the prize fan never interleaves, then + runs the standard pick + WithOpenPrizeCards flow and the win check.""" + pid = player_id or self.player_id + if count <= 0: + return + await self.flush_choreography() + await self.session._take_prizes(pid, count) + prizes = self.board.find_player_area(pid, "prizePile") + if prizes is not None and self.board.prizes_dealt.get(pid) \ + and not prizes.children: + await self.session.end_game(pid, "Took all Prize cards") + + async def win_game(self, reason: str = "") -> None: + """Declares the effect's owner the winner (Unown V; raises GameOver).""" + await self.session.end_game(self.player_id, reason or "Victory") + async def discard_stadium(self) -> Optional[BoardEntity]: """Discards the in-play Stadium to its owner's discard; returns it or None.""" stadium = self.stadium_in_play() @@ -1290,6 +1749,9 @@ async def resolve_attack(session, player_id: str, attacker: PokemonEntity, effect = ability.effect if ability else None title = ability.title if ability else action_id ctx._copy_chain.append(title) + session.turn_state.attacks_used.append( + (attacker.entity_id, attacker.archetype_id, title) + ) if effect is None or effect is unimplemented: if effect is unimplemented: @@ -1302,11 +1764,47 @@ async def resolve_attack(session, player_id: str, attacker: PokemonEntity, await effect(ctx) await _send_attack_bracket(session, ctx, action_id, title) + # ON_DAMAGED_BY_ATTACK fires after the attack choreography but BEFORE the + # knockout stacks move ("even if this Pokemon is Knocked Out"). + await _fire_damaged_by_attack_triggers(session, ctx) await session.resolve_knockouts(ctx) for hook in ctx.deferred_actions: await hook() +async def _fire_damaged_by_attack_triggers(session, ctx: EffectContext): + """Fires ON_DAMAGED_BY_ATTACK for every Pokemon the attack damaged; the + trigger ctx carries damaged_by / damage_amount / pre_hit_hp.""" + if not ctx.attack_damage: + return + board = session.board_state + snapshot: List[Tuple[PokemonEntity, str, Ability, int, int]] = [] + for entity_id, (dealt, pre_hit) in ctx.attack_damage.items(): + pokemon = board.get_entity(entity_id) + if not isinstance(pokemon, PokemonEntity): + continue + owner_id = pokemon.owning_player_id + if owner_id is None: + continue + locked = ability_locked(board, pokemon) + for entry in pokemon.get_attribute(AttrID.PIE_ABILITIES) or []: + if not isinstance(entry, dict): + continue + ability = ABILITIES_BY_ID.get(entry.get("abilityID")) + if ability is None or not ability.has_trigger(Triggers.ON_DAMAGED_BY_ATTACK): + continue + if locked and not ability.is_granted: + continue + snapshot.append((pokemon, owner_id, ability, dealt, pre_hit)) + for pokemon, owner_id, ability, dealt, pre_hit in snapshot: + def _setup(c, _dealt=dealt, _pre=pre_hit): + c.damaged_by = ctx.attacker + c.damage_amount = _dealt + c.pre_hit_hp = _pre + await resolve_triggered_ability(session, owner_id, pokemon, ability, + ctx_setup=_setup) + + async def resolve_triggered_ability( session, player_id: str, pokemon: PokemonEntity, ability: Ability, ctx_setup: Optional[Callable[[EffectContext], None]] = None, @@ -1337,8 +1835,9 @@ resolve_on_play_ability = resolve_triggered_ability async def resolve_activated_ability(session, player_id: str, pokemon: PokemonEntity, - ability: Ability): - """Runs a player-activated ability (Activations.ONCE_PER_TURN / VSTAR). + ability: Ability) -> EffectContext: + """Runs a player-activated ability (Activations.ONCE_PER_TURN / VSTAR); + returns its ctx (the executor reads ctx.ends_turn off it). The caller has already validated usability and marked the once-per-turn / once-per-game bookkeeping. @@ -1351,6 +1850,10 @@ async def resolve_activated_ability(session, player_id: str, pokemon: PokemonEnt ) elif ability.effect is not None: await ability.effect(ctx) + # Ability(ends_turn=True) only bites when the effect actually did + # something (a declined "you may" queues no messages). + if getattr(ability, "ends_turn", False) and ctx._messages: + ctx.ends_turn = True # Tuck the pulled-back ability panel home on the user's client. viewer = session.players.get(player_id) if viewer is not None: @@ -1358,6 +1861,7 @@ async def resolve_activated_ability(session, player_id: str, pokemon: PokemonEnt [viewer], GameSequence.DISMISS_ABILITY_SELECT, [] ) await _send_ability_brackets(session, ctx, pokemon, ability) + return ctx async def _send_ability_brackets(session, ctx: EffectContext, @@ -1398,6 +1902,13 @@ async def _send_ability_brackets(session, ctx: EffectContext, # The Attack executor dereferences the playmat's attack-source [0]. await session._broadcast_attack_sources([pokemon.entity_id]) + # Out-of-zone sources (usable_from hand/discard) default the orb to the + # source's PILE rather than the card itself. + fallback = [pokemon.entity_id] + parent = getattr(pokemon, "parent", None) + if parent is not None \ + and parent.get_attribute(AttrID.NAME) in ("hand", "discard"): + fallback = [parent.entity_id] orb = session._build_msg( OutboundMsg.NON_DAMAGING_TARGETS_EFFECT.value, { @@ -1406,7 +1917,7 @@ async def _send_ability_brackets(session, ctx: EffectContext, # exist, and ONLY r.u clears opponentTargetSelectArea -- an empty # list leaves the source floating on the opposing client. "targets": ctx.visual_targets or ctx._visual_sources - or [pokemon.entity_id], + or fallback, }, ) for pid, viewer in session.players.items(): diff --git a/spirit/game/session/game_session.py b/spirit/game/session/game_session.py index 9ff85c9..47eb6ab 100644 --- a/spirit/game/session/game_session.py +++ b/spirit/game/session/game_session.py @@ -70,7 +70,8 @@ from spirit.game.attributes import ( TrainerType, ) from spirit.game.data_utils import ( - ABILITIES_BY_ID, Ability, Triggers, def_for, prize_value, unimplemented, + ABILITIES_BY_ID, Ability, Activations, Triggers, def_for, prize_value, + subtypes_for, unimplemented, ) from spirit.database.player_data import COINS_PER_WIN, COINS_PER_LOSS, grant_coins from spirit.database.versus_data import award_match_points, get_progress @@ -86,7 +87,9 @@ from .effects import ( resolve_triggered_ability, ) from .passives import ( - ability_locked, active_passives, effective_max_hp, effective_retreat_cost, + ability_locked, active_passives, burn_recovery_blocked, + effective_bench_capacity, effective_max_hp, effective_retreat_cost, + tool_slots_free, ) from .legal_actions import ( ACTION_ATTACH_TOOL, @@ -102,7 +105,6 @@ from .legal_actions import ( compute_legal_actions, copy_attack_choice_node, energy_provided_count, - pokemon_without_tool, ) @@ -116,6 +118,10 @@ EOG_STAT_KEYS = ( # Recursion cap on ON_KNOCKED_OUT triggers cascading into further knockouts. _MAX_KO_TRIGGER_DEPTH = 4 +# Ceiling on an AIPlayer prompt wait (simulated answers land in ~1.5s); a +# prompt no simulation answers must never hang the gameplay task. +AI_PROMPT_GRACE_SECONDS = 15.0 + class GameOver(Exception): """Raised once the game has been decided; unwinds the gameplay sequence.""" @@ -188,6 +194,9 @@ class GameOptions: class GameSession: + # Pacing sleeps for client choreography; headless harnesses flip this off. + choreography_pauses: bool = True + def __init__(self, game_id: str, pairing: Dict[str, Any]): self.game_id: str = game_id self.pairing: Dict[str, Any] = pairing @@ -241,6 +250,8 @@ class GameSession: pid: {} for pid in pairing["players"].keys() } self.match_started_at: float = time.time() + # Set by declare_winner: {"winner", "loser", "reason"} once decided. + self.game_result: Optional[Dict[str, str]] = None # Per-player selection offer counters. The pregame coin flip uses # counters 1-2, so later offers continue from 3. self._selection_counters: Dict[str, int] = { @@ -264,6 +275,11 @@ class GameSession: task.add_done_callback(self._background_tasks.discard) return task + async def choreo_pause(self, seconds: float): + """Sleep that paces client animations; no-op when pauses are disabled.""" + if self.choreography_pauses: + await asyncio.sleep(seconds) + def cleanup(self): """Cancels any pending futures, tasks, and cleans up references.""" logging.info(f"[Session {self.game_id}] Cleaning up session.") @@ -527,7 +543,22 @@ class GameSession: await player.send_packet(OutboundMsg.SEQUENCE_MESSAGE.value, envelope) try: while True: - reply = await player.pending_choice_future + if isinstance(player, AIPlayer): + # AI prompts resolve only via simulated tasks (pregame coin + # flip); anything else must never hang the gameplay task. + try: + reply = await asyncio.wait_for( + player.pending_choice_future, AI_PROMPT_GRACE_SECONDS + ) + except asyncio.TimeoutError: + logging.error( + f"[Session {self.game_id}] Prompt '{msg_name}' hit " + f"the wire for AI player {player.screen_name} with " + f"no auto-answer; returning a null selection." + ) + return {"selection": None} + else: + reply = await player.pending_choice_future reply_counter = reply.get("counter") if isinstance(reply, dict) else None if expected_counter is None or reply_counter in (None, expected_counter): return reply @@ -858,13 +889,31 @@ class GameSession: def clear_pokemon_effects(self, pokemon) -> bool: """Wipes every effect that ends when a Pokemon leaves the Active spot - or play entirely: Special Conditions (attr + bookkeeping) and attack - locks keyed to it. Returns True iff it had any Special Conditions.""" + or play entirely: Special Conditions (attr + bookkeeping), attack and + retreat locks, temporary passives, and entity-keyed history stamps. + Returns True iff it had any Special Conditions.""" had_conditions = bool(pokemon.get_attribute(AttrID.SPECIAL_CONDITIONS)) pokemon.set_attribute(AttrID.SPECIAL_CONDITIONS, []) self.clear_condition_state(pokemon.entity_id) - for key in [k for k in self.turn_state.attack_locks if k[0] == pokemon.entity_id]: - self.turn_state.attack_locks.pop(key, None) + entity_id = pokemon.entity_id + state = self.turn_state + for key in [k for k in state.attack_locks if k[0] == entity_id]: + state.attack_locks.pop(key, None) + state.retreat_locks.pop(entity_id, None) + state.attach_restrictions.pop(entity_id, None) + for entity_map in (state.damage_taken, state.damage_taken_last_turn, + state.became_active_turn): + entity_map.pop(entity_id, None) + # retreated_entities is deliberately kept: the retreat executor stamps + # it right after calling this on the retreating Pokemon. + for entity_set in (state.healed_entities, state.healed_entities_last_turn, + state.turn_draw_entity_ids, + state.turn_draw_entity_ids_last_turn): + entity_set.discard(entity_id) + self.board_state.temporary_passives = [ + tp for tp in self.board_state.temporary_passives + if tp.carrier_entity_id != entity_id + ] return had_conditions def reset_pokemon_damage(self, pokemon) -> None: @@ -898,6 +947,8 @@ class GameSession: self.sleep_checkup_coins.pop(pokemon.entity_id, None) elif condition == SpecialConditions.PARALYZED: self.paralyzed_since.pop(pokemon.entity_id, None) + elif condition == SpecialConditions.POISONED: + self.poison_counters.pop(pokemon.entity_id, None) return self._condition_attr_msg(pokemon) def _place_damage_effect_msg(self, target_id: str, amount: int) -> Dict[str, Any]: @@ -1186,6 +1237,49 @@ class GameSession: break return by_group + def _view_cards_offer_info(self, cards: List[Any], prompt: str) -> Dict[str, Any]: + """Reveal-browser node with NOTHING selectable (view-only): all faces + in revealEntities, empty validTargets, minimum 0 so Done is always + enabled.""" + inner = { + "name": SelectionKind.ENTITY_LIST.value, + "selected": True, + "targetPrompt": {"id": ""}, + "validTargets": [], + "numberToSelect": 0, + "minimumToSelect": 0, + "forced": False, + } + return { + "name": SelectionKind.COMPOSITE_REVEAL.value, + "selected": True, + "targetPrompt": {"id": prompt}, + "revealEntities": {c.entity_id: c.serialize_attributes() for c in cards}, + "ordered": False, + "selections": [inner], + "validTargets": [], + "numberToSelect": 0, + "minimumToSelect": 0, + "forced": False, + } + + async def prompt_view_cards( + self, player_id: str, source_entity_id: str, cards: List[Any], + prompt: str = "Revealed cards", + ) -> None: + """View-only reveal browser (E4 reveal_hand): the viewer looks at the + cards and clicks Done; nothing is selectable. AI viewers skip. + needs live client verification: zero-count picked strip.""" + player = self.players.get(player_id) + if player is None or isinstance(player, AIPlayer) or not cards: + return + reveal_info = self._view_cards_offer_info(cards, prompt) + await self._run_pick_offer( + player_id, source_entity_id, reveal_info, + SelectionKind.COMPOSITE_REVEAL.value, + [], 0, 0, False, + ) + async def prompt_entity_picker( self, player_id: str, @@ -1423,7 +1517,7 @@ class GameSession: continue ability_id = entry.get("abilityID") ability = ABILITIES_BY_ID.get(ability_id) if ability_id else None - if ability is not None and ability.trigger == Triggers.ON_KNOCKED_OUT: + if ability is not None and ability.has_trigger(Triggers.ON_KNOCKED_OUT): ko_triggers.append((pokemon, owner_id, ability)) # Special-energy leave-play hooks (Gift Energy's draw) snapshotted with @@ -1441,7 +1535,52 @@ class GameSession: if hook is not None and hook is not unimplemented: energy_ko_hooks.append((owner_id, hook)) - prize_awards: Dict[str, int] = {} + # Prize counts/destinations evaluate BEFORE any stack moves so the + # KO'd Pokemon's own passives and Special Conditions still count. + passive_pairs = active_passives(self.board_state) + prize_plans: List[Tuple[str, int, str]] = [] + ally_triggers: List[Tuple[PokemonEntity, str, Ability, PokemonEntity, bool]] = [] + for pokemon in ctx.knockouts: + owner_id = pokemon.owning_player_id + if owner_id is None: + continue + count = prize_value(pokemon.archetype_id) + for passive, carrier in passive_pairs: + count = passive.modify_prizes_for_knockout(pokemon, ctx, count, carrier) + mode = next( + (m for passive, carrier in passive_pairs + for m in [passive.prize_destination(pokemon, ctx, carrier)] if m), + "hand", + ) + prize_plans.append((self._opponent_id(owner_id), max(0, count), mode)) + ko_from_attack = is_attack_ko and ctx.attacker.owning_player_id != owner_id + for ally in self.board_state.pokemon_in_play(owner_id): + if ally is pokemon or ally in ctx.knockouts: + continue + locked = ability_locked(self.board_state, ally) + for entry in ally.get_attribute(AttrID.PIE_ABILITIES) or []: + if not isinstance(entry, dict): + continue + ability = ABILITIES_BY_ID.get(entry.get("abilityID")) + if ability is None \ + or not ability.has_trigger(Triggers.ON_ALLY_KNOCKED_OUT): + continue + if locked and not ability.is_granted: + continue + ally_triggers.append( + (ally, owner_id, ability, pokemon, ko_from_attack)) + # ON_ALLY_KNOCKED_OUT fires pre-discard: the KO'd stack is still on + # board with its energies attached (Exp. Share moves one off it). + for ally, owner_id, ability, victim, from_attack in ally_triggers: + def _ally_setup(c, _victim=victim, _from_attack=from_attack): + c.ko_pokemon = _victim + c.ko_from_attack = _from_attack + c.ko_attacker = ctx.attacker if _from_attack else None + await resolve_triggered_ability( + self, owner_id, ally, ability, ctx_setup=_ally_setup, + _ko_depth=_ko_depth + 1, + ) + promotions: List[str] = [] for pokemon in ctx.knockouts: owner_id = pokemon.owning_player_id @@ -1506,17 +1645,27 @@ class GameSession: await self.send_game_sequence( list(self.players.values()), GameSequence.KNOCKOUT, moves ) - prize_awards[taker_id] = ( - prize_awards.get(taker_id, 0) + prize_value(pokemon.archetype_id) - ) + if is_attack_ko and ctx.attacker.owning_player_id != owner_id: + self.turn_state.kos_by_attack.setdefault(owner_id, []).append({ + "archetype_id": pokemon.archetype_id, + "subtypes": list(subtypes_for(pokemon.archetype_id)), + }) if was_active and owner_id not in promotions: promotions.append(owner_id) logging.info( f"[Session {self.game_id}] {pokemon.entity_id} " f"({self.players[owner_id].screen_name}) was knocked out." ) - if ctx.extra_prizes and ctx.player_id in prize_awards: - prize_awards[ctx.player_id] += ctx.extra_prizes + prize_awards: Dict[Tuple[str, str], int] = {} + for taker_id, count, mode in prize_plans: + if count > 0: + prize_awards[(taker_id, mode)] = ( + prize_awards.get((taker_id, mode), 0) + count + ) + if ctx.extra_prizes and any(t == ctx.player_id for t, _, _ in prize_plans): + prize_awards[(ctx.player_id, "hand")] = ( + prize_awards.get((ctx.player_id, "hand"), 0) + ctx.extra_prizes + ) ctx.knockouts.clear() # Fire ON_KNOCKED_OUT triggers after the Knockout brackets/HP resets, @@ -1552,9 +1701,9 @@ class GameSession: if hook_ctx._messages: await self._flush_effect_runs(hook_ctx) - for taker_id, count in prize_awards.items(): - await self._take_prizes(taker_id, count) - for taker_id in prize_awards: + for (taker_id, mode), count in prize_awards.items(): + await self._take_prizes(taker_id, count, destination=mode) + for taker_id in {t for t, _ in prize_awards}: prizes = self.board_state.find_player_area(taker_id, "prizePile") if prizes is not None and self.board_state.prizes_dealt.get(taker_id) \ and not prizes.children: @@ -1573,8 +1722,11 @@ class GameSession: if trigger_ctx.knockouts: await self.resolve_knockouts(trigger_ctx, _ko_depth=_ko_depth + 1) - async def _take_prizes(self, player_id: str, count: int): - """The player picks `count` face-down prizes and takes them to hand.""" + async def _take_prizes(self, player_id: str, count: int, + destination: str = "hand"): + """The player picks `count` face-down prizes and takes them to hand; + a non-hand `destination` (Billowing Smoke's discard, Barbaracle's + lostZone) reroutes the picked prizes there after the reveal.""" prize_area = self.board_state.find_player_area(player_id, "prizePile") hand_area = self.board_state.find_player_area(player_id, "hand") if not prize_area or not hand_area: @@ -1604,6 +1756,9 @@ class GameSession: if not moves: return gap_msg = self._refresh_prize_gaps(player_id, prize_area) + self.turn_state.prizes_taken[player_id] = ( + self.turn_state.prizes_taken.get(player_id, 0) + len(moves) + ) self.stat_add(player_id, "prizecardstaken", len(moves)) logging.info( f"[Session {self.game_id}] {player.screen_name} takes " @@ -1617,6 +1772,30 @@ class GameSession: GameSequence.WITH_OPEN_PRIZE_CARDS, ((intros + moves) if pid == player_id else list(moves)) + [gap_msg], ) + if destination != "hand": + # Reroute the taken prizes: a plain GroupedMove after the reveal + # flow, with intros to the opponent (public-pile arrival reveals). + # needs live client verification: non-hand prize-take choreography + dest_area = self.board_state.find_player_area(player_id, destination) + if dest_area is None: + return + reroute_intros, reroute_moves = [], [] + for card in cards: + if card is None or card.parent is not hand_area: + continue + position = len(dest_area.children) + if self.board_state.move_card(card.entity_id, dest_area.entity_id): + reroute_intros.append(self._entity_introduced_msg(card)) + reroute_moves.append(self._entity_moved_msg( + card.entity_id, dest_area.entity_id, position)) + if reroute_moves: + opponent = self.players.get(self._opponent_id(player_id)) + if opponent is not None: + await self.send_game_sequence( + [opponent], GameSequence.SERIAL_SEQUENCE, reroute_intros) + await self.send_game_sequence( + list(self.players.values()), GameSequence.GROUPED_MOVE, + reroute_moves) def _refresh_prize_gaps(self, player_id: str, prize_area) -> Dict[str, Any]: """Marks taken prize positions in the pile's AREA_EMPTY_SLOTS so the @@ -1798,6 +1977,8 @@ class GameSession: player_id, PROMPT_CHOOSE_NEW_ACTIVE, candidates, TARGET_TYPE_ACTIVE ) board.move_card(picked.entity_id, active_area.entity_id) + self.turn_state.became_active_turn[picked.entity_id] = \ + self.turn_state.turn_number logging.info( f"[Session {self.game_id}] {player.screen_name} promoted " f"{picked.entity_id} to Active." @@ -1808,6 +1989,7 @@ class GameSession: GameSequence.PLAY_ACTIVE, [self._entity_moved_msg(picked.entity_id, active_area.entity_id, 0)], ) + await self.fire_move_to_active_triggers(picked) return True def stat_add(self, player_id: str, key: str, amount: int = 1): @@ -1893,6 +2075,17 @@ class GameSession: OutboundMsg.CURRENT_WALLET.value, profile.get_wallet_data() ) + def declare_winner(self, winner_id: str, reason: str) -> Dict[str, str]: + """Pure game-over bookkeeping (no wire/DB): flips the phase and + records the result; end_game rides it, headless tests call it alone.""" + self.game_result = { + "winner": winner_id, + "loser": self._opponent_id(winner_id), + "reason": reason, + } + self.game_phase = GamePhase.GAME_OVER + return self.game_result + async def end_game(self, winner_id: str, reason: str): """Announces the result on both clients and unwinds the game loop.""" loser_id = self._opponent_id(winner_id) @@ -1947,7 +2140,7 @@ class GameSession: await self._record_legacy_tournament_result(winner_id) await self._push_wallet_updates() await self._push_account_updates() - self.game_phase = GamePhase.GAME_OVER + self.declare_winner(winner_id, reason) raise GameOver() async def _record_legacy_tournament_result(self, winner_id: str): @@ -2118,19 +2311,25 @@ class GameSession: knocked_out = await self._apply_raw_damage( active, amount, GameSequence.POISON_DAMAGE.value ) - await asyncio.sleep(1.5) + await self.choreo_pause(1.5) if knocked_out: await self._resolve_raw_knockout(active) async def _checkup_burn(self, player_id: str, active): - """Burn tick: 20 raw damage, then a flip -- heads cures.""" + """Burn tick: 2 damage counters (passives may modify), then a flip -- + heads cures; blocks_burn_recovery skips the flip entirely.""" + counters = 2 + for passive, carrier in active_passives(self.board_state): + counters = passive.modify_burn_counters(counters, active, carrier) knocked_out = await self._apply_raw_damage( - active, 20, GameSequence.BURN_DAMAGE.value + active, max(0, counters) * 10, GameSequence.BURN_DAMAGE.value ) if knocked_out: - await asyncio.sleep(1.5) + await self.choreo_pause(1.5) await self._resolve_raw_knockout(active) return + if burn_recovery_blocked(self.board_state, active): + return flip = random.choice([0, 1]) heads = flip == 0 self.stat_add(player_id, "headsflipped", 1 if heads else 0) @@ -2149,7 +2348,7 @@ class GameSession: }, )], ) - await asyncio.sleep(3.0) + await self.choreo_pause(3.0) if heads: await self.send_game_sequence( list(self.players.values()), GameSequence.REMOVE_SPECIAL_CONDITION, @@ -2179,7 +2378,7 @@ class GameSession: }, )], ) - await asyncio.sleep(3.0) + await self.choreo_pause(3.0) if woke: await self.send_game_sequence( list(self.players.values()), @@ -2470,7 +2669,7 @@ class GameSession: """Advances the turn counter, announces the active player, and draws.""" # Last turn's stat-modifier PiPs (Power Tablet) expire with the turn. await self._clear_turn_visualizations() - self.turn_state.begin_turn(active_id) + self.turn_state.begin_turn(active_id, self.board_state) player = self.players[active_id] logging.info( f"[Session {self.game_id}] Turn {self.turn_state.turn_number} " @@ -2484,6 +2683,7 @@ class GameSession: self._opponent_id(active_id), f"{player.screen_name} has no cards left to draw", ) + self.turn_state.turn_draw_entity_ids.update(d["entity_id"] for d in drawn) announce = self._build_msg( OutboundMsg.ACTIVE_PLAYER_SET.value, {"gameID": self.game_id, "accountID": active_id}, @@ -2640,11 +2840,11 @@ class GameSession: elif description == ACTION_EVOLVE: await self._execute_evolve(player_id, card, entry, target_ids) elif description == ACTION_USE_TRAINER: - await self._execute_play_trainer(player_id, card) + return await self._execute_play_trainer(player_id, card) elif description == ACTION_PLAY_STADIUM: await self._execute_play_stadium(player_id, card) elif description == ACTION_USE_ABILITY: - await self._execute_use_ability(player_id, card, entry) + return await self._execute_use_ability(player_id, card, entry) elif description == ACTION_USE_ATTACK: return await self._execute_attack(player_id, card, entry) elif description == ACTION_RETREAT: @@ -2674,7 +2874,8 @@ class GameSession: async def _execute_play_basic(self, player_id: str, card): """Plays a Basic Pokemon from hand onto the bench.""" bench_area = self.board_state.find_player_area(player_id, "bench") - if not bench_area or len(bench_area.children) >= BENCH_CAPACITY: + if not bench_area or len(bench_area.children) >= \ + effective_bench_capacity(self.board_state, player_id): logging.warning(f"[Session {self.game_id}] Bench unavailable; re-offering.") return # Lowest free SLOT, not list length: the client never renumbers bench @@ -2695,7 +2896,8 @@ class GameSession: ) await self._fire_triggered_abilities(player_id, card, Triggers.ON_PLAY) - async def _fire_triggered_abilities(self, player_id: str, card, trigger: str): + async def _fire_triggered_abilities(self, player_id: str, card, trigger: str, + ctx_setup=None): """Runs a Pokemon's abilities matching `trigger` (on-play, on-evolve, on-knocked-out, between-turns); resolve_knockouts already flushed any knockouts the effect caused, so the extra call here is empty-safe.""" @@ -2704,12 +2906,13 @@ class GameSession: continue ability_id = entry.get("abilityID") ability = ABILITIES_BY_ID.get(ability_id) if ability_id else None - if ability is not None and ability.trigger == trigger: + if ability is not None and ability.has_trigger(trigger): # "1 per turn" abilities shared by name across copies (Dark Asset). if ability.shared_once_per_turn \ and ability.shared_once_per_turn in self.turn_state.used_named_abilities: continue - trigger_ctx = await resolve_triggered_ability(self, player_id, card, ability) + trigger_ctx = await resolve_triggered_ability( + self, player_id, card, ability, ctx_setup=ctx_setup) # Only a resolved effect (not a declined "you may") consumes the # shared-name turn limit. if ability.shared_once_per_turn and trigger_ctx is not None \ @@ -2718,6 +2921,34 @@ class GameSession: if trigger_ctx is not None and trigger_ctx.knockouts: await self.resolve_knockouts(trigger_ctx) + async def fire_energy_attached_triggers(self, attaching_player_id: str, + energy, receiver): + """ON_ENERGY_ATTACHED for every in-play Pokemon on BOTH sides + (Arctozolt observes the opponent's attach); the trigger ctx carries + attaching_player_id / attached_energy / energy_receiver.""" + def _setup(c): + c.attaching_player_id = attaching_player_id + c.attached_energy = energy + c.energy_receiver = receiver + for pid in self._turn_order(): + for pokemon in list(self.board_state.pokemon_in_play(pid)): + await self._fire_triggered_abilities( + pid, pokemon, Triggers.ON_ENERGY_ATTACHED, ctx_setup=_setup) + + async def fire_move_to_active_triggers(self, pokemon): + """ON_MOVE_TO_ACTIVE (Cinderace Libero), at most once per entity per + turn regardless of how often it re-enters the Active spot.""" + if pokemon is None: + return + ts = self.turn_state + if pokemon.entity_id in ts.on_move_to_active_fired: + return + ts.on_move_to_active_fired.add(pokemon.entity_id) + owner_id = pokemon.owning_player_id + if owner_id: + await self._fire_triggered_abilities( + owner_id, pokemon, Triggers.ON_MOVE_TO_ACTIVE) + async def _execute_attach_energy(self, player_id, card, entry, target_ids): """Attaches an energy card underneath the chosen Pokemon, honoring the definition's attach hooks (cost, restrictions, on-attach effects).""" @@ -2777,6 +3008,8 @@ class GameSession: if attach_ctx is not None: await self._flush_effect_runs(attach_ctx) await self.resolve_knockouts(attach_ctx) + # A manual attach from hand is what ON_ENERGY_ATTACHED observes. + await self.fire_energy_attached_triggers(player_id, card, target) async def _refresh_max_hp(self, pokemon, max_before: int): """Re-broadcasts HP after a stack change shifted a max-HP bonus @@ -3016,7 +3249,7 @@ class GameSession: """Attaches a Pokemon Tool underneath the chosen Pokemon (one each).""" target_id = self._validated_target(entry, target_ids) target = self.board_state.get_entity(target_id) if target_id else None - if target is None or not pokemon_without_tool(target): + if target is None or tool_slots_free(self.board_state, target) <= 0: logging.warning( f"[Session {self.game_id}] Tool attach without a valid " f"target ({target_ids}); re-offering." @@ -3041,8 +3274,9 @@ class GameSession: await self._refresh_max_hp(target, max_before) await self.refresh_granted_abilities(target) - async def _execute_use_ability(self, player_id, card, entry): - """Activates a Pokemon's usable ability (once-per-turn / VSTAR).""" + async def _execute_use_ability(self, player_id, card, entry) -> bool: + """Activates a Pokemon's usable ability (once-per-turn / VSTAR). + Returns True when the ability ends the turn (Ability.ends_turn).""" action_id = entry["selectableAction"]["actionID"] ability = ABILITIES_BY_ID.get(action_id) if ability is None: @@ -3050,8 +3284,9 @@ class GameSession: f"[Session {self.game_id}] Ability {action_id} on " f"{card.entity_id} has no registered definition; ignoring." ) - return - self.turn_state.used_abilities.add((card.entity_id, action_id)) + return False + if ability.activation != Activations.UNLIMITED: + self.turn_state.used_abilities.add((card.entity_id, action_id)) if ability.shared_once_per_turn: self.turn_state.used_named_abilities.add(ability.shared_once_per_turn) if ability.vstar: @@ -3060,19 +3295,34 @@ class GameSession: f"[Session {self.game_id}] {self.players[player_id].screen_name} " f"uses ability '{ability.title}'." ) - await resolve_activated_ability(self, player_id, card, ability) + ctx = await resolve_activated_ability(self, player_id, card, ability) + return ctx is not None and ctx.ends_turn async def _execute_evolve(self, player_id, card, entry, target_ids): """Evolves the target: the evolution takes its slot, the old stack tucks underneath.""" target_id = self._validated_target(entry, target_ids) target = self.board_state.get_entity(target_id) if target_id else None - area = target.parent if target else None - if not target or not area: + if not target or not target.parent: logging.warning( f"[Session {self.game_id}] Evolve without a valid target " f"({target_ids}); re-offering." ) return + await self.perform_evolution(player_id, card, target) + + async def perform_evolution(self, player_id, evolution_card, target, + from_zone_intro: bool = False) -> bool: + """State + bracket core of an evolution (shared by the play executor + and effect-driven evolution, which bypasses the may-evolve rules). + + from_zone_intro sends the evolution card's intro to the OWNER too + (hidden-zone sources like the deck; the wrap-FX rule needs the card's + attrs applied before the Evolve bracket on both viewers). + """ + card = evolution_card + area = target.parent if target is not None else None + if not target or not area: + return False slot = self.board_state.bench_slot_of(target) @@ -3085,7 +3335,7 @@ class GameSession: moves = [] if not self.board_state.move_card(card.entity_id, area.entity_id, slot): - return + return False moves.append(self._entity_moved_msg(card.entity_id, area.entity_id, slot)) # Re-nest pre-existing attachments, then the pre-evolution card itself. @@ -3112,6 +3362,15 @@ class GameSession: self._entity_id_data_effect_msg("From", target.entity_id), self._entity_id_data_effect_msg("Into", card.entity_id), ] + if from_zone_intro: + # Deck-sourced evolution: the owner is blind too, so their intro + # rides its own SerialSequence bracket before the Evolve bracket. + owner_viewer = self.players.get(player_id) + if owner_viewer is not None: + await self.send_game_sequence( + [owner_viewer], GameSequence.SERIAL_SEQUENCE, + [self._entity_introduced_msg(card)], + ) await self._send_play_sequence( player_id, GameSequence.EVOLVE, data_effects + moves, [card] ) @@ -3127,7 +3386,7 @@ class GameSession: self.turn_state.mark_entered_play(card.entity_id) logging.info( f"[Session {self.game_id}] {self.players[player_id].screen_name} " - f"evolved {target_id} into {card.entity_id}." + f"evolved {target.entity_id} into {card.entity_id}." ) # needs live client verification: condition marker clears on evolve @@ -3139,18 +3398,21 @@ class GameSession: ) await self._fire_triggered_abilities(player_id, card, Triggers.ON_EVOLVE) + return True - async def _execute_play_trainer(self, player_id, card): - """Plays an Item/Supporter: revealed onto activeTrainer, effect resolves, then discarded.""" + async def _execute_play_trainer(self, player_id, card) -> bool: + """Plays an Item/Supporter: revealed onto activeTrainer, effect resolves, + then discarded. Returns True when the effect ended the turn (Rotom Bike).""" trainer_area = self.board_state.find_global_area("activeTrainer") discard_area = self.board_state.find_player_area(player_id, "discard") if not trainer_area or not discard_area: - return + return False if not self.board_state.move_card(card.entity_id, trainer_area.entity_id): - return + return False card.owning_player_id = player_id # global area move clears the owner if card.get_attribute(AttrID.TRAINER_TYPE) == TrainerType.SUPPORTER.value: self.turn_state.supporter_played = True + self._record_trainer_played(card) self.stat_add(player_id, "trainersplayed") # Stale attack sources make k.z skip the reveal suppression @@ -3201,6 +3463,16 @@ class GameSession: # the choreography flushes. for hook in ctx.deferred_actions: await hook() + return ctx is not None and ctx.ends_turn + + def _record_trainer_played(self, card): + """Stamps the trainer into this turn's history ledger.""" + name = card.get_attribute(AttrID.NAME) + name = name.get("id", "") if isinstance(name, dict) else (name or "") + display = getattr(def_for(card.archetype_id), "display_name", None) or name + self.turn_state.trainers_played.append( + (card.archetype_id, display, card.get_attribute(AttrID.TRAINER_TYPE)) + ) async def _execute_play_stadium(self, player_id, card): """Plays a Stadium: the previous one goes to its owner's discard.""" @@ -3222,6 +3494,7 @@ class GameSession: return # Keep the owner so the next stadium can route this one to the right discard. card.owning_player_id = player_id + self._record_trainer_played(card) self.stat_add(player_id, "trainersplayed") moves.append(self._entity_moved_msg(card.entity_id, stadium_area.entity_id, position)) logging.info( @@ -3259,7 +3532,7 @@ class GameSession: e for e in (self.board_state.get_entity(eid) for eid in discard_ids) if isinstance(e, EnergyEntity) ] - paid = sum(energy_provided_count(e) for e in energies) + paid = sum(energy_provided_count(e, self.board_state) for e in energies) if paid < cost: logging.warning( f"[Session {self.game_id}] Retreat cost {cost} underpaid " @@ -3305,6 +3578,11 @@ class GameSession: [self._entity_id_data_effect_msg("Target", card.entity_id), self._condition_attr_msg(card)], ) + # History stamps AFTER the effect clear (it prunes entity-keyed maps). + self.turn_state.retreated_entities.add(card.entity_id) + self.turn_state.became_active_turn[new_active.entity_id] = \ + self.turn_state.turn_number + await self.fire_move_to_active_triggers(new_active) async def _execute_attack(self, player_id, card, entry) -> bool: """Resolves an attack through the effect engine; attacking ends the turn.""" @@ -3365,7 +3643,7 @@ class GameSession: )], ) # needs live client verification: confusion-heads pulled-out card tuck - await asyncio.sleep(3.0) + await self.choreo_pause(3.0) if heads: return True knocked_out = await self._apply_raw_damage( diff --git a/spirit/game/session/legal_actions.py b/spirit/game/session/legal_actions.py index e08d1f2..a9c94b8 100644 --- a/spirit/game/session/legal_actions.py +++ b/spirit/game/session/legal_actions.py @@ -26,12 +26,22 @@ from spirit.game.models.board import ( TrainerEntity, ) from .constants import ( - BENCH_CAPACITY, PROMPT_RETREAT_COST, PROMPT_RETREAT_NEW_ACTIVE, SelectionKind, ) -from .passives import ability_locked, effective_attack_cost, effective_retreat_cost +from .passives import ( + ability_locked, + can_evolve_early, + effective_attack_cost, + effective_bench_capacity, + effective_retreat_cost, + energy_provided_options, + evolution_blocked, + retreat_blocked, + tool_slots_free, + trainer_play_blocked, +) # Semantic action names from the client's Actions enum / SelectableActionUtil. @@ -63,6 +73,9 @@ _WILDCARD_COST_TYPES = (PokemonTypes.NO_COLOR.value, PokemonTypes.COLORLESS.valu _ACTION_ID_NAMESPACE = uuid.UUID("f6c1b1de-5e1a-4b52-9c40-1d1c4e6a7b0d") +# Sentinel lock horizon: stays locked until the Pokemon leaves the Active spot. +LOCK_UNTIL_LEAVES_ACTIVE = 10 ** 9 + def action_id_for(entity_id: str, verb: str) -> str: """Deterministic GUID action ID (must be a GUID: the client runs new Guid(id)).""" @@ -91,11 +104,48 @@ class TurnState: # (entity_id, ability_id) -> last turn number the attack stays locked # ("during your next turn, this Pokemon can't use ..."). attack_locks: Dict[Tuple[str, str], int] = field(default_factory=dict) - # Turn-scoped attacker-side damage boosts (Power Tablet); cleared each turn. + # entity_id -> last turn number retreat stays locked ("the Defending + # Pokemon can't retreat"); LOCK_UNTIL_LEAVES_ACTIVE = until it leaves. + retreat_locks: Dict[str, int] = field(default_factory=dict) + # Turn-scoped attacker-side damage boosts (Power Tablet); pruned by + # expires_after_turn each begin_turn (None = this turn only). damage_modifiers: List[Any] = field(default_factory=list) + # --- two-turn history ledgers, rotated this-turn -> last-turn --- + # (archetype_id, display_name, trainer_type) per trainer/stadium played. + trainers_played: List[Tuple[str, str, int]] = field(default_factory=list) + # (entity_id, archetype_id, attack_title) per declared attack. + attacks_used: List[Tuple[str, str, str]] = field(default_factory=list) + # victim owner player_id -> [{archetype_id, subtypes}] for attack KOs. + kos_by_attack: Dict[str, List[Dict[str, Any]]] = field(default_factory=dict) + # entity_id -> damage dealt to it by the other side this turn. + damage_taken: Dict[str, int] = field(default_factory=dict) + # player_id -> prize cards taken this turn. + prizes_taken: Dict[str, int] = field(default_factory=dict) + retreated_entities: Set[str] = field(default_factory=set) + healed_entities: Set[str] = field(default_factory=set) + turn_draw_entity_ids: Set[str] = field(default_factory=set) + trainers_played_last_turn: List[Tuple[str, str, int]] = field(default_factory=list) + attacks_used_last_turn: List[Tuple[str, str, str]] = field(default_factory=list) + kos_by_attack_last_turn: Dict[str, List[Dict[str, Any]]] = field(default_factory=dict) + damage_taken_last_turn: Dict[str, int] = field(default_factory=dict) + prizes_taken_last_turn: Dict[str, int] = field(default_factory=dict) + retreated_entities_last_turn: Set[str] = field(default_factory=set) + healed_entities_last_turn: Set[str] = field(default_factory=set) + turn_draw_entity_ids_last_turn: Set[str] = field(default_factory=set) + # entity_id -> turn it last moved into the Active spot (persistent stamp). + became_active_turn: Dict[str, int] = field(default_factory=dict) + # player_id -> [(card_predicate, expires_after_turn)] play restrictions + # ("your opponent can't play Item cards during their next turn"); a None + # expiry holds until cleared. + play_locks: Dict[str, List[Tuple[Any, Optional[int]]]] = field(default_factory=dict) + # entity_id -> last turn number energy may not be attached to it (Masquerain). + attach_restrictions: Dict[str, int] = field(default_factory=dict) + # Entities whose ON_MOVE_TO_ACTIVE trigger already fired this turn. + on_move_to_active_fired: Set[str] = field(default_factory=set) - def begin_turn(self, player_id: str): - """Advances to the next turn and resets the once-per-turn flags.""" + def begin_turn(self, player_id: str, board: Optional[Any] = None): + """Advances to the next turn, resets the once-per-turn flags, rotates + the two-turn history, and prunes expired turn-scoped effects.""" self.turn_number += 1 self.active_player_id = player_id self.supporter_played = False @@ -103,7 +153,43 @@ class TurnState: self.retreated = False self.used_abilities = set() self.used_named_abilities = set() - self.damage_modifiers = [] + self.damage_modifiers = [ + m for m in self.damage_modifiers + if getattr(m, "expires_after_turn", None) is not None + and m.expires_after_turn >= self.turn_number + ] + self.trainers_played_last_turn = self.trainers_played + self.trainers_played = [] + self.attacks_used_last_turn = self.attacks_used + self.attacks_used = [] + self.kos_by_attack_last_turn = self.kos_by_attack + self.kos_by_attack = {} + self.damage_taken_last_turn = self.damage_taken + self.damage_taken = {} + self.prizes_taken_last_turn = self.prizes_taken + self.prizes_taken = {} + self.retreated_entities_last_turn = self.retreated_entities + self.retreated_entities = set() + self.healed_entities_last_turn = self.healed_entities + self.healed_entities = set() + self.turn_draw_entity_ids_last_turn = self.turn_draw_entity_ids + self.turn_draw_entity_ids = set() + self.on_move_to_active_fired = set() + self.play_locks = { + pid: kept for pid, locks in self.play_locks.items() + if (kept := [(p, exp) for p, exp in locks + if exp is None or exp >= self.turn_number]) + } + self.attach_restrictions = { + eid: exp for eid, exp in self.attach_restrictions.items() + if exp >= self.turn_number + } + if board is not None: + board.temporary_passives = [ + tp for tp in (getattr(board, "temporary_passives", None) or []) + if tp.expires_after_turn is None + or tp.expires_after_turn >= self.turn_number + ] def mark_entered_play(self, entity_id: str): self.entered_play_turn[entity_id] = self.turn_number @@ -115,6 +201,38 @@ class TurnState: def attack_locked(self, entity_id: str, ability_id: str) -> bool: return self.turn_number <= self.attack_locks.get((entity_id, ability_id), 0) + def lock_retreat(self, entity_id: str, through_turn: Optional[int] = None): + """Blocks retreat through `through_turn` (default: the opponent's next turn).""" + self.retreat_locks[entity_id] = ( + self.turn_number + 1 if through_turn is None else through_turn + ) + + def retreat_locked(self, entity_id: str) -> bool: + return self.turn_number <= self.retreat_locks.get(entity_id, 0) + + def lock_plays(self, player_id: str, predicate, through_turn: Optional[int] = None): + """Forbids `player_id` playing hand cards matching `predicate` + (default: through their next turn).""" + self.play_locks.setdefault(player_id, []).append( + (predicate, self.turn_number + 1 if through_turn is None else through_turn) + ) + + def play_locked(self, player_id: str, card: Any) -> bool: + return any( + (exp is None or self.turn_number <= exp) and pred(card) + for pred, exp in self.play_locks.get(player_id, []) + ) + + def restrict_attachments(self, entity_id: str, through_turn: Optional[int] = None): + """Forbids energy attachments onto `entity_id` (default: through the + opponent's next turn).""" + self.attach_restrictions[entity_id] = ( + self.turn_number + 1 if through_turn is None else through_turn + ) + + def attach_restricted(self, entity_id: str) -> bool: + return self.turn_number <= self.attach_restrictions.get(entity_id, -1) + def may_evolve_target(self, entity_id: str) -> bool: """A Pokemon may evolve only if it has been in play since a previous turn, and never during either player's first turn (turns 1 and 2).""" @@ -173,22 +291,23 @@ def _target_map_entry( } -def energy_provided_count(energy: EnergyEntity) -> int: - """How much one energy card pays toward a cost (client s.y: max option length).""" - info = energy.get_attribute(AttrID.ENERGY_INFO) or {} - return max((len(option) for option in info.get("options", [])), default=1) +def energy_provided_count(energy: EnergyEntity, board: Optional[BoardState] = None) -> int: + """How much one energy card pays toward a cost (client s.y: max option + length); with a board, provided-modifying passives apply.""" + options = energy_provided_options(board, energy) + return max((len(option) for option in options), default=1) -def _energy_provided_types(energy: EnergyEntity) -> set: - """The set of types one energy can provide (union of ENERGY_INFO options).""" - info = energy.get_attribute(AttrID.ENERGY_INFO) or {} +def _energy_provided_types(energy: EnergyEntity, board: Optional[BoardState] = None) -> set: + """The set of types one energy can provide (union of the options).""" provided = set() - for option in info.get("options", []): + for option in energy_provided_options(board, energy): provided.update(option) return provided -def attack_cost_satisfied(cost: Dict[str, int], energies: List[EnergyEntity]) -> bool: +def attack_cost_satisfied(cost: Dict[str, int], energies: List[EnergyEntity], + board: Optional[BoardState] = None) -> bool: """Whether the attached energies can pay an attack's cost. Each card pays up to its provided count (Double Turbo pays 2); typed @@ -196,7 +315,7 @@ def attack_cost_satisfied(cost: Dict[str, int], energies: List[EnergyEntity]) -> accepts whatever capacity remains. """ pool = [ - {"types": _energy_provided_types(e), "count": energy_provided_count(e)} + {"types": _energy_provided_types(e, board), "count": energy_provided_count(e, board)} for e in energies ] colorless_needed = 0 @@ -252,12 +371,14 @@ def compute_legal_actions( in_play = board.pokemon_in_play(player_id) in_play_ids = [p.entity_id for p in in_play] - bench_has_space = len(bench_area.children) < BENCH_CAPACITY + bench_has_space = len(bench_area.children) < effective_bench_capacity(board, player_id) for card in hand_area.children: if isinstance(card, PokemonEntity): stage = card.get_attribute(AttrID.STAGE) if stage == PokemonStage.BASIC.value: + if getattr(def_for(card.archetype_id), "unplayable_from_hand", False): + continue # Shedinja: enters play only via an effect if bench_has_space: # The bench area is the drop target; without it the drag # dead-ends at the ActionsNode and nothing highlights. @@ -274,7 +395,9 @@ def compute_legal_actions( evolve_targets = [ p.entity_id for p in in_play if p.get_attribute(AttrID.EVOLUTION_LOGIC_NAME) == evolves_from - and state.may_evolve_target(p.entity_id) + and not evolution_blocked(board, player_id, p) + and (state.may_evolve_target(p.entity_id) + or can_evolve_early(board, p)) ] if evolve_targets: entries.append(_target_map_entry( @@ -286,6 +409,8 @@ def compute_legal_actions( elif isinstance(card, EnergyEntity): if state.energy_attached or not in_play_ids: continue + if state.play_locked(player_id, card): + continue definition = def_for(card.archetype_id) condition = getattr(definition, "attach_condition", None) if condition is not None and not condition(board, player_id): @@ -293,7 +418,8 @@ def compute_legal_actions( attach_to = getattr(definition, "attach_to", None) targets = [ p.entity_id for p in in_play - if attach_to is None or attach_to(p) + if (attach_to is None or attach_to(p)) + and not state.attach_restricted(p.entity_id) ] if targets: entries.append(_target_map_entry( @@ -304,6 +430,9 @@ def compute_legal_actions( elif isinstance(card, TrainerEntity): trainer_type = card.get_attribute(AttrID.TRAINER_TYPE) + if state.play_locked(player_id, card) \ + or trainer_play_blocked(board, player_id, card): + continue definition = def_for(card.archetype_id) condition = getattr(definition, "condition", None) if condition is not None and not condition(board, player_id): @@ -326,8 +455,11 @@ def compute_legal_actions( action_id_for(card.entity_id, "stadium"), ACTION_PLAY_STADIUM, )) elif trainer_type == TrainerType.POKEMON_TOOL.value: + tool_attach_to = getattr(definition, "attach_to", None) tool_targets = [ - p.entity_id for p in in_play if pokemon_without_tool(p) + p.entity_id for p in in_play + if tool_slots_free(board, p) > 0 + and (tool_attach_to is None or tool_attach_to(p)) ] if tool_targets: entries.append(_target_map_entry( @@ -337,11 +469,11 @@ def compute_legal_actions( )) entries.extend(_ability_entries(board, state, player_id, game_id, in_play)) + entries.extend(_out_of_zone_ability_entries(board, state, player_id, game_id)) entries.extend(_stadium_ability_entries(board, state, player_id, game_id)) - # The player going first cannot attack on their first turn (turn 1). - if state.turn_number != 1 and not _active_immobilized(board, player_id): - entries.extend(_attack_entries(board, state, player_id, game_id)) if not _active_immobilized(board, player_id): + # The turn-1 attack gate lives in _attack_entries (usable_first_turn). + entries.extend(_attack_entries(board, state, player_id, game_id)) entries.extend(_retreat_entry(board, state, player_id, game_id)) return entries @@ -362,14 +494,16 @@ def _ability_entries( continue ability_id = entry.get("abilityID") ability = ABILITIES_BY_ID.get(ability_id) if ability_id else None - if ability is None or ability.activation != Activations.ONCE_PER_TURN: + if ability is None or ability.activation not in ( + Activations.ONCE_PER_TURN, Activations.UNLIMITED): continue # Path to the Peak locks a Pokemon's own Abilities, but a Tool- # granted ability (Forest Seal Stone) lives on the tool, not the # Pokemon, so it stays usable. if locked and not ability.is_granted: continue - if (pokemon.entity_id, ability_id) in state.used_abilities: + if ability.activation != Activations.UNLIMITED \ + and (pokemon.entity_id, ability_id) in state.used_abilities: continue if ability.vstar and player_id in state.vstar_used: continue @@ -389,6 +523,48 @@ def _ability_entries( return entries +def _out_of_zone_ability_entries( + board: BoardState, state: TurnState, player_id: str, game_id: str +) -> List[Dict[str, Any]]: + """Abilities flagged usable_from='hand'/'discard' on the player's cards in + those zones, offered with the OutOfPlay selection flow (b.h). + + Ruling: ability locks (Path to the Peak) read "Pokemon in play", so they + do NOT gate hand/discard sources. + """ + entries = [] + for zone in ("hand", "discard"): + area = board.find_player_area(player_id, zone) + for card in (area.children if area else []): + for entry in card.get_attribute(AttrID.PIE_ABILITIES) or []: + if not isinstance(entry, dict): + continue + ability_id = entry.get("abilityID") + ability = ABILITIES_BY_ID.get(ability_id) if ability_id else None + if ability is None or ability.usable_from != zone: + continue + if ability.effect is None: + continue + if ability.activation not in ( + Activations.ONCE_PER_TURN, Activations.UNLIMITED): + continue + if ability.activation != Activations.UNLIMITED \ + and (card.entity_id, ability_id) in state.used_abilities: + continue + if ability.vstar and player_id in state.vstar_used: + continue + if ability.shared_once_per_turn \ + and ability.shared_once_per_turn in state.used_named_abilities: + continue + if ability.condition and not ability.condition(board, player_id, card): + continue + entries.append(_target_map_entry( + game_id, card.entity_id, ability_id, ACTION_USE_ABILITY, + selection_type=SelectionKind.OUT_OF_PLAY.value, + )) + return entries + + def _stadium_ability_entries( board: BoardState, state: TurnState, player_id: str, game_id: str ) -> List[Dict[str, Any]]: @@ -423,6 +599,8 @@ def _retreat_entry( active = board.active_pokemon(player_id) if not active: return [] + if state.retreat_locked(active.entity_id) or retreat_blocked(board, active): + return [] bench_area = board.find_player_area(player_id, "bench") bench_ids = [ c.entity_id for c in (bench_area.children if bench_area else []) @@ -432,7 +610,7 @@ def _retreat_entry( return [] cost = effective_retreat_cost(board, active) energies = board.attached_energies(active) - if sum(energy_provided_count(e) for e in energies) < cost: + if sum(energy_provided_count(e, board) for e in energies) < cost: return [] # New active FIRST, cost LAST: the Done button only renders on a node with @@ -499,6 +677,11 @@ def _attack_entries( if state.attack_locked(active.entity_id, ability_id): continue definition = ABILITIES_BY_ID.get(ability_id) + # The player going first cannot attack on turn 1 unless the attack + # explicitly allows it (Indeedee's Watch Over). + if state.turn_number == 1 \ + and not getattr(definition, "usable_first_turn", False): + continue if definition is not None and definition.vstar \ and player_id in state.vstar_used: continue @@ -508,7 +691,7 @@ def _attack_entries( continue # Cost-modifying passives (e.g. Excited Heart) apply here. cost = effective_attack_cost(board, active, ability.get("cost") or {}) - if attack_cost_satisfied(cost, energies): + if attack_cost_satisfied(cost, energies, board): entries.append(_target_map_entry( game_id, active.entity_id, ability_id, ACTION_USE_ATTACK, selection_type=SELECTION_TYPE_PANEL, diff --git a/spirit/game/session/passives.py b/spirit/game/session/passives.py index e34d6b7..64d8a63 100644 --- a/spirit/game/session/passives.py +++ b/spirit/game/session/passives.py @@ -8,11 +8,12 @@ or max HP, so effects switch on/off purely by board position. """ from dataclasses import dataclass -from typing import Any, Dict, List, Optional, Set, Tuple +from typing import Any, Callable, Dict, List, Optional, Set, Tuple -from spirit.game.attributes import AttrID, PokemonTypes +from spirit.game.attributes import AttrID, PokemonTypes, TrainerType from spirit.game.data_utils import ABILITIES_BY_ID, def_for, subtypes_for from spirit.game.models.board import ( + BENCH_SLOT_COUNT, BoardEntity, BoardState, CardEntity, @@ -22,6 +23,9 @@ from spirit.game.models.board import ( WEAKNESS_MULTIPLIER = 2 RESISTANCE_REDUCTION = 30 +# Areas whose top-level cards keep a temporary passive alive. +_IN_PLAY_AREAS = ("activePokemonArea", "bench", "activeStadium") + @dataclass class TurnDamageModifier: @@ -31,6 +35,23 @@ class TurnDamageModifier: player_id: str requires_subtype: Optional[str] = None opposing_active_only: bool = True + # None = this turn only; otherwise the last turn number it still applies. + expires_after_turn: Optional[int] = None + # Only while THIS entity attacks (Scyther's next-turn self boost). + source_entity_id: Optional[str] = None + # Only while resolving this attack title (Metagross' Fullmetal Impact rider). + attack_title: Optional[str] = None + # Arbitrary attacker gate (Ludicolo/Rapidash predicates). + source_predicate: Optional[Callable[[BoardEntity], bool]] = None + + +@dataclass +class TempPassive: + """An effect-granted passive with a lifetime. expires_after_turn None = + until the carrier leaves the Active spot / play (clear_pokemon_effects).""" + passive: "Passive" + carrier_entity_id: str + expires_after_turn: Optional[int] = None class DamageCalc: @@ -45,6 +66,7 @@ class DamageCalc: is_attack: bool = True, apply_modifiers: bool = True, ignore_target_effects: bool = False, + attack_title: Optional[str] = None, ): self.board = board self.attacker = attacker @@ -55,7 +77,15 @@ class DamageCalc: self.is_attack = is_attack # Weakness/Resistance stage runs only versus the defending Active. self.apply_modifiers = apply_modifiers + # Title of the attack being resolved (matches TurnDamageModifier riders). + self.attack_title = attack_title self.weakness_applies = True + self.weakness_multiplier = WEAKNESS_MULTIPLIER + # Rewritable by modify_weakness hooks (Spiritomb/UnownVSTAR overrides). + self.weak_types: List[Any] = list( + target.get_attribute(AttrID.WEAKNESS_TYPES) or [] + ) + self.resistance_applies = True self.weakness_hit = False self.resistance_hit = False self.prevented = False @@ -89,11 +119,18 @@ class Passive: the Pokemon an attachment rides on). """ + # Same-key passives count once in effective_max_hp (Abomasnow stacks). + stacking_key: Optional[str] = None + def modify_damage_dealt(self, calc: DamageCalc, carrier: BoardEntity): """Attacker-side "does more/less damage" step (runs before W/R).""" def modify_weakness(self, calc: DamageCalc, carrier: BoardEntity): - """May clear calc.weakness_applies (e.g. "... have no Weakness").""" + """May clear calc.weakness_applies (e.g. "... have no Weakness"), or + rewrite calc.weak_types / calc.weakness_multiplier.""" + + def modify_resistance(self, calc: DamageCalc, carrier: BoardEntity): + """May clear calc.resistance_applies ("... has no Resistance").""" def modify_damage_taken(self, calc: DamageCalc, carrier: BoardEntity): """Defender-side "takes less damage" step (runs after W/R).""" @@ -130,10 +167,94 @@ class Passive: """True to turn off `pokemon`'s Abilities (Path to the Peak style).""" return False + def blocks_retreat(self, pokemon: PokemonEntity, carrier: BoardEntity) -> bool: + """True to forbid `pokemon` from retreating (Octolock, Flygon).""" + return False + + def blocks_special_conditions( + self, target: PokemonEntity, condition: Any, carrier: BoardEntity + ) -> bool: + """True to shield `target` from having `condition` applied to it.""" + return False + + def prevents_healing(self, target: PokemonEntity, carrier: BoardEntity) -> bool: + """True to prevent healing damage from `target` (Mimikyu SWSH3).""" + return False + def knockout_destination(self, pokemon: PokemonEntity, carrier: BoardEntity) -> Optional[str]: """Area name replacing "discard" for a knocked-out Pokemon (e.g. "lostZone").""" return None + def modify_prizes_for_knockout( + self, pokemon: PokemonEntity, ctx: Any, count: int, carrier: BoardEntity + ) -> int: + """Prize count the opponent takes for knocking out `pokemon` (Komala); + evaluated BEFORE the stack moves, so board/conditions still count.""" + return count + + def prize_destination( + self, pokemon: PokemonEntity, ctx: Any, carrier: BoardEntity + ) -> Optional[str]: + """Area name replacing "hand" for the prizes taken for this knockout + ("discard" = Billowing Smoke, "lostZone" = Barbaracle).""" + return None + + def modify_energy_provided( + self, options: List[List[int]], energy: BoardEntity, + holder: Optional[PokemonEntity], board: BoardState, + ) -> List[List[int]]: + """Rewrites an energy's provided-type options (Charizard PGO doubling).""" + return options + + def suppresses_special_energy(self, energy: BoardEntity, carrier: BoardEntity) -> bool: + """True to neutralize a Special Energy (Temple of Sinnoh): it loses + its passive and provides only Colorless.""" + return False + + def blocks_trainer_play( + self, card: BoardEntity, player_id: str, carrier: BoardEntity + ) -> bool: + """True to forbid `player_id` playing `card` from hand (Vileplume).""" + return False + + def may_evolve_early(self, pokemon: PokemonEntity, carrier: BoardEntity) -> bool: + """True to exempt `pokemon` from the just-played/first-turn evolution + gates (Caterpie's Adaptive Evolution).""" + return False + + def blocks_evolution( + self, player_id: str, target: PokemonEntity, carrier: BoardEntity + ) -> bool: + """True to forbid `player_id` evolving `target` at all (Dracovish).""" + return False + + def modify_burn_counters( + self, counters: int, pokemon: PokemonEntity, carrier: BoardEntity + ) -> int: + """Damage counters the Burn checkup tick places (default 2).""" + return counters + + def blocks_burn_recovery(self, pokemon: PokemonEntity, carrier: BoardEntity) -> bool: + """True to skip the Burn recovery flip entirely (stays Burned).""" + return False + + def tool_capacity(self, pokemon: PokemonEntity, carrier: BoardEntity) -> int: + """Pokemon Tools `pokemon` may hold (GarbodorVMAX 2); highest wins.""" + return 1 + + def bench_capacity(self, player_id: str, carrier: BoardEntity) -> Optional[int]: + """Bench size override for `player_id` (Collapsed Stadium 4); None = + no opinion, the smallest override wins.""" + return None + + def blocks_ability_effects(self, target: PokemonEntity, carrier: BoardEntity) -> bool: + """True to shield `target` from opponents' Ability effects (Corviknight VMAX).""" + return False + + def blocks_discard(self, card: BoardEntity, carrier: BoardEntity) -> bool: + """True to keep `card` from being discarded by an opponent's effect.""" + return False + def carrier_pokemon(carrier: BoardEntity) -> Optional[PokemonEntity]: """The in-play Pokemon a passive rides: the carrier itself, or the @@ -154,6 +275,11 @@ def _collect_passives(board: BoardState) -> List[Tuple[Passive, BoardEntity, boo triples: List[Tuple[Passive, BoardEntity, bool]] = [] for player_id in board.player_ids: for pokemon in board.pokemon_in_play(player_id): + # Card-level PokemonCardDef(passive=): rules text that is not an + # Ability, so ability locks never switch it off. + card_passive = getattr(def_for(pokemon.archetype_id), "passive", None) + if card_passive is not None: + triples.append((card_passive, pokemon, False)) for entry in pokemon.get_attribute(AttrID.PIE_ABILITIES) or []: if not isinstance(entry, dict): continue @@ -175,9 +301,25 @@ def _collect_passives(board: BoardState) -> List[Tuple[Passive, BoardEntity, boo passive = getattr(definition, "passive", None) if passive is not None: triples.append((passive, stadium, False)) + # Effect-granted temporary passives; dead carriers are silently skipped. + for temp in getattr(board, "temporary_passives", None) or []: + carrier = board.get_entity(temp.carrier_entity_id) + if carrier is None or not _carrier_in_play(carrier): + continue + triples.append((temp.passive, carrier, False)) return triples +def _carrier_in_play(entity: BoardEntity) -> bool: + """Whether an entity (or the stack it is attached under) sits in play.""" + node = entity + while isinstance(getattr(node, "parent", None), CardEntity): + node = node.parent + parent = getattr(node, "parent", None) + return parent is not None \ + and parent.get_attribute(AttrID.NAME) in _IN_PLAY_AREAS + + def ability_locked(board: BoardState, pokemon: PokemonEntity) -> bool: """Whether a passive (Path to the Peak) is disabling `pokemon`'s Abilities. @@ -188,13 +330,24 @@ def ability_locked(board: BoardState, pokemon: PokemonEntity) -> bool: return any(p.blocks_abilities(pokemon, c) for p, c, _ in _collect_passives(board)) +def _suppressed_special_energy( + triples: List[Tuple[Passive, BoardEntity, bool]], entity: BoardEntity +) -> bool: + """Whether `entity` is a Special Energy neutralized by a suppression + passive (evaluated on the UNFILTERED set, like ability locks).""" + if not entity.get_attribute(AttrID.IS_SPECIAL_ENERGY): + return False + return any(p.suppresses_special_energy(entity, c) for p, c, _ in triples) + + def active_passives(board: BoardState) -> List[Tuple[Passive, BoardEntity]]: """All (passive, carrier) pairs currently switched on by board position. Ability passives count only for top-level in-play Pokemon (a tucked pre-evolution's ability is off); tool/energy/stadium passives count anywhere in an in-play stack. Ability-contributed passives on a Pokemon - whose own Abilities are locked (Path to the Peak) are excluded. + whose own Abilities are locked (Path to the Peak) are excluded, as are + passives riding a suppressed Special Energy (Temple of Sinnoh). """ triples = _collect_passives(board) @@ -202,7 +355,8 @@ def active_passives(board: BoardState) -> List[Tuple[Passive, BoardEntity]]: return any(p.blocks_abilities(pokemon, c) for p, c, _ in triples) return [(p, c) for p, c, is_ability in triples - if not (is_ability and blocked(c))] + if not (is_ability and blocked(c)) + and not _suppressed_special_energy(triples, c)] def _descendants(entity: BoardEntity) -> List[BoardEntity]: @@ -222,18 +376,24 @@ def compute_damage( apply_modifiers: bool = True, ignore_target_effects: bool = False, ignore_weakness: bool = False, + ignore_resistance: bool = False, + attack_title: Optional[str] = None, ) -> DamageCalc: """Runs the full damage pipeline: dealt-modifiers, W/R, taken-modifiers, prevention. Returns the finished DamageCalc. ignore_weakness (Cramorant's Spit Innocently) skips the Weakness stage but - keeps Resistance -- distinct from apply_modifiers, which gates both. + keeps Resistance -- distinct from apply_modifiers, which gates both; + ignore_resistance (Gallade V's Buster Swing) is the exact mirror. """ calc = DamageCalc(board, attacker, target, base, is_attack=is_attack, apply_modifiers=apply_modifiers, - ignore_target_effects=ignore_target_effects) + ignore_target_effects=ignore_target_effects, + attack_title=attack_title) if ignore_weakness: calc.weakness_applies = False + if ignore_resistance: + calc.resistance_applies = False passives = active_passives(board) if calc.is_attack: @@ -246,6 +406,12 @@ def compute_damage( continue if mod.requires_subtype and mod.requires_subtype not in subtypes_for(attacker.archetype_id): continue + if mod.source_entity_id and attacker.entity_id != mod.source_entity_id: + continue + if mod.attack_title and calc.attack_title != mod.attack_title: + continue + if mod.source_predicate is not None and not mod.source_predicate(attacker): + continue if mod.opposing_active_only and not (calc.is_opposing and calc.to_active): continue calc.amount += mod.amount @@ -254,13 +420,13 @@ def compute_damage( if calc.apply_modifiers and attacker is not None: for passive, carrier in passives: passive.modify_weakness(calc, carrier) + passive.modify_resistance(calc, carrier) attacker_types = attacker.get_attribute(AttrID.POKEMON_TYPES) or [] - weak_types = target.get_attribute(AttrID.WEAKNESS_TYPES) or [] - if calc.weakness_applies and any(t in weak_types for t in attacker_types): + if calc.weakness_applies and any(t in calc.weak_types for t in attacker_types): calc.weakness_hit = True - calc.amount *= WEAKNESS_MULTIPLIER + calc.amount *= calc.weakness_multiplier resist_type = target.get_attribute(AttrID.RESISTANCE_TYPES) - if resist_type in attacker_types: + if calc.resistance_applies and resist_type in attacker_types: calc.resistance_hit = True calc.amount = max(0, calc.amount - RESISTANCE_REDUCTION) @@ -298,14 +464,21 @@ def effective_retreat_cost(board: BoardState, pokemon: PokemonEntity) -> int: def effective_max_hp(board: BoardState, pokemon: PokemonEntity) -> int: - """Printed max HP plus every max-HP bonus riding the Pokemon's stack.""" + """Printed max HP plus every max-HP bonus riding the Pokemon's stack; + passives sharing a stacking_key count once (Abomasnow).""" printed = pokemon.attribute_originals.get( AttrID.HP.value, pokemon.get_attribute(AttrID.HP, 0) ) - bonus = sum( - passive.max_hp_bonus(pokemon, carrier) - for passive, carrier in active_passives(board) - ) + bonus = 0 + seen_keys: Set[str] = set() + for passive, carrier in active_passives(board): + key = passive.stacking_key + if key is not None and key in seen_keys: + continue + gained = passive.max_hp_bonus(pokemon, carrier) + if gained and key is not None: + seen_keys.add(key) + bonus += gained return printed + bonus @@ -315,3 +488,119 @@ def attack_effects_blocked(board: BoardState, target: PokemonEntity) -> bool: passive.blocks_attack_effects(target, carrier) for passive, carrier in active_passives(board) ) + + +def retreat_blocked(board: BoardState, pokemon: PokemonEntity) -> bool: + """Whether a passive forbids `pokemon` from retreating.""" + return any( + passive.blocks_retreat(pokemon, carrier) + for passive, carrier in active_passives(board) + ) + + +def conditions_blocked(board: BoardState, target: PokemonEntity, condition: Any) -> bool: + """Whether a passive shields `target` from the given Special Condition.""" + return any( + passive.blocks_special_conditions(target, condition, carrier) + for passive, carrier in active_passives(board) + ) + + +def healing_blocked(board: BoardState, target: PokemonEntity) -> bool: + """Whether a passive prevents healing damage from `target`.""" + return any( + passive.prevents_healing(target, carrier) + for passive, carrier in active_passives(board) + ) + + +def ability_effects_blocked(board: BoardState, target: PokemonEntity) -> bool: + """Whether a passive shields `target` from opposing Ability effects.""" + return any( + passive.blocks_ability_effects(target, carrier) + for passive, carrier in active_passives(board) + ) + + +def trainer_play_blocked(board: BoardState, player_id: str, card: BoardEntity) -> bool: + """Whether a continuous passive forbids playing `card` from hand.""" + return any( + passive.blocks_trainer_play(card, player_id, carrier) + for passive, carrier in active_passives(board) + ) + + +def discard_blocked(board: BoardState, card: BoardEntity) -> bool: + """Whether a passive protects `card` from an opponent-caused discard.""" + return any( + passive.blocks_discard(card, carrier) + for passive, carrier in active_passives(board) + ) + + +def evolution_blocked(board: BoardState, player_id: str, target: PokemonEntity) -> bool: + """Whether a passive forbids `player_id` evolving `target` (Dracovish).""" + return any( + passive.blocks_evolution(player_id, target, carrier) + for passive, carrier in active_passives(board) + ) + + +def can_evolve_early(board: BoardState, pokemon: PokemonEntity) -> bool: + """Whether a passive exempts `pokemon` from the evolution turn gates.""" + return any( + passive.may_evolve_early(pokemon, carrier) + for passive, carrier in active_passives(board) + ) + + +def burn_recovery_blocked(board: BoardState, pokemon: PokemonEntity) -> bool: + """Whether a passive skips the Burn recovery flip for `pokemon`.""" + return any( + passive.blocks_burn_recovery(pokemon, carrier) + for passive, carrier in active_passives(board) + ) + + +def special_energy_suppressed(board: BoardState, energy: BoardEntity) -> bool: + """Whether `energy` is a Special Energy neutralized by a passive.""" + return _suppressed_special_energy(_collect_passives(board), energy) + + +def energy_provided_options(board: Optional[BoardState], energy: BoardEntity) -> List[List[int]]: + """An energy card's provided-type options after suppression (a suppressed + Special Energy provides only Colorless) and modify_energy_provided hooks.""" + info = energy.get_attribute(AttrID.ENERGY_INFO) or {} + options = [list(option) for option in info.get("options", [])] + if board is None: + return options + if special_energy_suppressed(board, energy): + options = [[PokemonTypes.COLORLESS.value]] + holder = carrier_pokemon(energy) + for passive, carrier in active_passives(board): + options = passive.modify_energy_provided(options, energy, holder, board) + return options + + +def effective_bench_capacity(board: BoardState, player_id: str) -> int: + """Bench size for `player_id` after capacity passives; the smallest + override wins (Collapsed Stadium caps an Eternatus board at 4).""" + values = [ + v for passive, carrier in active_passives(board) + for v in [passive.bench_capacity(player_id, carrier)] if v is not None + ] + return max(1, min(values)) if values else BENCH_SLOT_COUNT + + +def tool_slots_free(board: Optional[BoardState], pokemon: PokemonEntity) -> int: + """Open Pokemon Tool slots on `pokemon` (default capacity 1; the highest + tool_capacity passive wins).""" + capacity = 1 + if board is not None: + for passive, carrier in active_passives(board): + capacity = max(capacity, passive.tool_capacity(pokemon, carrier)) + attached = sum( + 1 for child in pokemon.children + if child.get_attribute(AttrID.TRAINER_TYPE) == TrainerType.POKEMON_TOOL.value + ) + return capacity - attached