mirror of
https://github.com/Bratah123/Spirit-PTCGO.git
synced 2026-09-09 13:15:38 -05:00
FEAT: new engine capabilities
This commit is contained in:
689
spirit/game/card_effects/attacks_common.py
Normal file
689
spirit/game/card_effects/attacks_common.py
Normal 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
|
||||
628
spirit/game/card_effects/passives_common.py
Normal file
628
spirit/game/card_effects/passives_common.py
Normal 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
|
||||
609
spirit/game/card_effects/support_common.py
Normal file
609
spirit/game/card_effects/support_common.py
Normal 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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.")
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user