mirror of
https://github.com/Bratah123/Spirit-PTCGO.git
synced 2026-09-09 05:05:30 -05:00
FEAT: place damage counters animation, escape rope logic, path to the peak shutting off seal stone
This commit is contained in:
@@ -493,9 +493,20 @@ def is_energy_card(card) -> bool:
|
||||
return card.get_attribute(AttrID.CARD_TYPE) == CardType.ENERGY.value
|
||||
|
||||
|
||||
def energy_provides_type(card, type_value) -> bool:
|
||||
"""Whether one attached energy can provide `type_value` (special energies
|
||||
declare provided types via ENERGY_INFO, not POKEMON_TYPES — Aurora)."""
|
||||
if not is_energy_card(card):
|
||||
return False
|
||||
info = card.get_attribute(AttrID.ENERGY_INFO) or {}
|
||||
for option in info.get("options", []):
|
||||
if type_value in option:
|
||||
return True
|
||||
return type_value in (card.get_attribute(AttrID.POKEMON_TYPES) or [])
|
||||
|
||||
|
||||
def is_lightning_energy(card) -> bool:
|
||||
types = card.get_attribute(AttrID.POKEMON_TYPES) or []
|
||||
return is_energy_card(card) and PokemonTypes.LIGHTNING.value in types
|
||||
return energy_provides_type(card, PokemonTypes.LIGHTNING.value)
|
||||
|
||||
|
||||
def is_pokemon_gx(archetype_id) -> bool:
|
||||
|
||||
@@ -532,16 +532,24 @@ async def evolution_incense(ctx):
|
||||
|
||||
|
||||
async def escape_rope(ctx):
|
||||
"""Each player switches their Active with a Benched Pokemon; the
|
||||
opponent switches first (no Bench, no switch)."""
|
||||
for pid in (ctx.opponent_id, ctx.player_id):
|
||||
bench = ctx.opponent_bench() if pid == ctx.opponent_id else ctx.my_bench()
|
||||
if not bench:
|
||||
continue
|
||||
"""Each player switches their Active with a Benched Pokemon; the opponent
|
||||
chooses first and their swap is shown to both clients before the Escape
|
||||
Rope player decides (no Bench, no switch)."""
|
||||
opp_bench = ctx.opponent_bench()
|
||||
if opp_bench:
|
||||
target = await ctx.choose_pokemon(
|
||||
bench, "Choose your new Active Pokémon", player_id=pid
|
||||
opp_bench, "Choose your new Active Pokémon", player_id=ctx.opponent_id
|
||||
)
|
||||
await ctx.switch_active(pid, target or bench[0])
|
||||
await ctx.switch_active(ctx.opponent_id, target or opp_bench[0])
|
||||
# Flush the opponent's swap so both clients see it land before the
|
||||
# Escape Rope player is prompted for their own switch.
|
||||
await ctx.flush_choreography()
|
||||
my_bench = ctx.my_bench()
|
||||
if my_bench:
|
||||
target = await ctx.choose_pokemon(
|
||||
my_bench, "Choose your new Active Pokémon", player_id=ctx.player_id
|
||||
)
|
||||
await ctx.switch_active(ctx.player_id, target or my_bench[0])
|
||||
|
||||
|
||||
async def lost_vacuum(ctx):
|
||||
@@ -760,7 +768,7 @@ async def mirage_gate(ctx):
|
||||
picks = await ctx.choose_cards(
|
||||
reps, 2, minimum=0,
|
||||
prompt="Choose up to 2 basic Energy cards of different types.",
|
||||
display_cards=reps,
|
||||
display_cards=deck_cards,
|
||||
)
|
||||
for energy in picks:
|
||||
label = labels[energy.entity_id]
|
||||
|
||||
@@ -133,6 +133,9 @@ class Ability:
|
||||
self.condition = condition
|
||||
# Assigned by the owning CardDefinition; must match SelectableAction.actionID.
|
||||
self.ability_id: Optional[str] = None
|
||||
# True when granted by an attached Tool (Forest Seal Stone): the ability
|
||||
# lives on the tool, not the Pokemon, so Path to the Peak can't lock it.
|
||||
self.is_granted: bool = False
|
||||
|
||||
def on_use(self, fn: Callable) -> Callable:
|
||||
"""Decorator alternative to the effect= parameter."""
|
||||
@@ -414,6 +417,7 @@ class PokemonToolCardDef(TrainerCardDef):
|
||||
for idx, a in enumerate(self.granted_abilities):
|
||||
if not a.ability_id:
|
||||
a.ability_id = ability_id_for(self.guid, idx)
|
||||
a.is_granted = True
|
||||
ABILITIES_BY_ID[a.ability_id] = a
|
||||
|
||||
class EnergyCardDef(CardDefinition):
|
||||
|
||||
@@ -211,6 +211,7 @@ class EffectContext:
|
||||
is_attack: Optional[bool] = None,
|
||||
ignore_target_effects: bool = False,
|
||||
ignore_weakness: bool = False,
|
||||
as_counters: bool = False,
|
||||
) -> int:
|
||||
"""Damages a Pokemon (default: the attack's printed damage onto the
|
||||
opponent's Active) and returns the final amount after modifiers.
|
||||
@@ -219,6 +220,9 @@ class EffectContext:
|
||||
(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.
|
||||
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).
|
||||
"""
|
||||
target = target if target is not None else self.defender
|
||||
if target is None:
|
||||
@@ -249,29 +253,40 @@ class EffectContext:
|
||||
remaining = max(0, current - calc.amount)
|
||||
target.set_attribute(AttrID.HP, remaining)
|
||||
|
||||
title = self.ability.title if self.ability else ""
|
||||
attacker_types = self.attacker.get_attribute(AttrID.POKEMON_TYPES) or []
|
||||
type_name = CLIENT_TYPE_NAMES.get(attacker_types[0], "Colorless") \
|
||||
if attacker_types else "Colorless"
|
||||
# m.m reads current HP when it plays, so the damage popup must precede
|
||||
# the HP AttributeModified for its knockout check to see pre-hit HP.
|
||||
self._queue(self.session._build_msg(
|
||||
OutboundMsg.CAKE_ATTACK_EFFECT.value,
|
||||
{
|
||||
"gameID": self.game_id,
|
||||
"damageSource": self.attacker.entity_id,
|
||||
"entityID": target.entity_id,
|
||||
"weaknessTriggered": calc.weakness_hit,
|
||||
"resistanceTrigger": calc.resistance_hit,
|
||||
"damageType": [type_name],
|
||||
"attackName": {"id": title},
|
||||
"damageAmount": calc.amount,
|
||||
"damageModification": 0,
|
||||
"visualType": VISUAL_DAMAGING,
|
||||
},
|
||||
))
|
||||
# m.p/m.m both read current HP when they play, so the damage FX must
|
||||
# precede the HP AttributeModified for the knockout check to see
|
||||
# pre-hit HP.
|
||||
if as_counters:
|
||||
# PlaceDamageEffect (UNSET condition => generic counter-drop, not
|
||||
# the poison/burn overlay); leave _dealt_opponent_damage False so
|
||||
# the attacker tucks via the non-damaging orb aimed at the targets.
|
||||
self._queue(self.session._place_damage_effect_msg(
|
||||
target.entity_id, calc.amount))
|
||||
if target.entity_id not in self.visual_targets:
|
||||
self.visual_targets.append(target.entity_id)
|
||||
else:
|
||||
title = self.ability.title if self.ability else ""
|
||||
attacker_types = self.attacker.get_attribute(AttrID.POKEMON_TYPES) or []
|
||||
type_name = CLIENT_TYPE_NAMES.get(attacker_types[0], "Colorless") \
|
||||
if attacker_types else "Colorless"
|
||||
self._queue(self.session._build_msg(
|
||||
OutboundMsg.CAKE_ATTACK_EFFECT.value,
|
||||
{
|
||||
"gameID": self.game_id,
|
||||
"damageSource": self.attacker.entity_id,
|
||||
"entityID": target.entity_id,
|
||||
"weaknessTriggered": calc.weakness_hit,
|
||||
"resistanceTrigger": calc.resistance_hit,
|
||||
"damageType": [type_name],
|
||||
"attackName": {"id": title},
|
||||
"damageAmount": calc.amount,
|
||||
"damageModification": 0,
|
||||
"visualType": VISUAL_DAMAGING,
|
||||
},
|
||||
))
|
||||
if target.owning_player_id != self.attacker.owning_player_id:
|
||||
self._dealt_opponent_damage = True
|
||||
if not as_counters:
|
||||
self._dealt_opponent_damage = True
|
||||
self.session.stat_add(self.player_id, "damagedealt", calc.amount)
|
||||
self.session.credit_card_damage(self.player_id, self.attacker, calc.amount)
|
||||
if is_attack:
|
||||
@@ -493,7 +508,7 @@ class EffectContext:
|
||||
continue
|
||||
await self.deal_damage(
|
||||
amount=counters * 10, target=by_id[entity_id],
|
||||
apply_modifiers=False, is_attack=False,
|
||||
apply_modifiers=False, is_attack=False, as_counters=True,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -1090,6 +1105,13 @@ class EffectContext:
|
||||
)
|
||||
return True
|
||||
|
||||
async def flush_choreography(self):
|
||||
"""Sends and clears the currently queued choreography brackets, so a
|
||||
following dialog resolves only after both clients see them land
|
||||
(Escape Rope: the opponent's swap shows before the player decides)."""
|
||||
await self.session._flush_effect_runs(self)
|
||||
self._messages.clear()
|
||||
|
||||
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()
|
||||
@@ -1284,7 +1306,7 @@ async def resolve_triggered_ability(
|
||||
"""Runs a triggered ability (on-play/on-evolve/on-knocked-out/between-turns)
|
||||
with the full activation choreography; returns its ctx, or None when the
|
||||
ability didn't run (locked, or no scripted effect)."""
|
||||
if ability_locked(session.board_state, pokemon):
|
||||
if ability_locked(session.board_state, pokemon) and not ability.is_granted:
|
||||
return None
|
||||
if ability.effect is None or ability.effect is unimplemented:
|
||||
if ability.effect is unimplemented:
|
||||
|
||||
@@ -356,8 +356,7 @@ def _ability_entries(
|
||||
"""Usable activated abilities on the player's in-play Pokemon."""
|
||||
entries = []
|
||||
for pokemon in in_play:
|
||||
if ability_locked(board, pokemon):
|
||||
continue
|
||||
locked = ability_locked(board, pokemon)
|
||||
for entry in pokemon.get_attribute(AttrID.PIE_ABILITIES) or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
@@ -365,6 +364,11 @@ def _ability_entries(
|
||||
ability = ABILITIES_BY_ID.get(ability_id) if ability_id else None
|
||||
if ability is None or ability.activation != Activations.ONCE_PER_TURN:
|
||||
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:
|
||||
continue
|
||||
if ability.vstar and player_id in state.vstar_used:
|
||||
|
||||
@@ -159,7 +159,9 @@ def _collect_passives(board: BoardState) -> List[Tuple[Passive, BoardEntity, boo
|
||||
continue
|
||||
ability = ABILITIES_BY_ID.get(entry.get("abilityID"))
|
||||
if ability is not None and ability.passive is not None:
|
||||
triples.append((ability.passive, pokemon, True))
|
||||
# A Tool-granted ability's passive rides the tool, not the
|
||||
# Pokemon, so Path to the Peak can't switch it off.
|
||||
triples.append((ability.passive, pokemon, not ability.is_granted))
|
||||
for attachment in _descendants(pokemon):
|
||||
if isinstance(attachment, PokemonEntity):
|
||||
continue # tucked pre-evolutions contribute nothing
|
||||
|
||||
Reference in New Issue
Block a user