FEAT: place damage counters animation, escape rope logic, path to the peak shutting off seal stone

This commit is contained in:
Brandon Nguyen
2026-07-06 20:02:49 -07:00
parent fdd9f58b20
commit 5c1b03290b
6 changed files with 89 additions and 38 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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