FEAT: new engine capabilities

This commit is contained in:
Brandon Nguyen
2026-07-12 22:33:51 -07:00
parent a384b8d807
commit 4c07223c42
10 changed files with 3407 additions and 124 deletions

View File

@@ -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 <condition>, 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

View File

@@ -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 <matching> 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

View File

@@ -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

View File

@@ -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 <this attack>."
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:

View File

@@ -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

View File

@@ -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.")

View File

@@ -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:
""""<player> can't play <cards matching predicate>" (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():

View File

@@ -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(

View File

@@ -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,

View File

@@ -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