diff --git a/spirit/game/card_effects/pokemon.py b/spirit/game/card_effects/pokemon.py index b6d16ec..6b89125 100644 --- a/spirit/game/card_effects/pokemon.py +++ b/spirit/game/card_effects/pokemon.py @@ -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: diff --git a/spirit/game/card_effects/trainers.py b/spirit/game/card_effects/trainers.py index d7b0111..1f0233d 100644 --- a/spirit/game/card_effects/trainers.py +++ b/spirit/game/card_effects/trainers.py @@ -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] diff --git a/spirit/game/data_utils.py b/spirit/game/data_utils.py index 376ba82..66630fd 100644 --- a/spirit/game/data_utils.py +++ b/spirit/game/data_utils.py @@ -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): diff --git a/spirit/game/session/effects.py b/spirit/game/session/effects.py index 6e126f4..4e2b3ab 100644 --- a/spirit/game/session/effects.py +++ b/spirit/game/session/effects.py @@ -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: diff --git a/spirit/game/session/legal_actions.py b/spirit/game/session/legal_actions.py index e2ccb46..e08d1f2 100644 --- a/spirit/game/session/legal_actions.py +++ b/spirit/game/session/legal_actions.py @@ -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: diff --git a/spirit/game/session/passives.py b/spirit/game/session/passives.py index d807824..e34d6b7 100644 --- a/spirit/game/session/passives.py +++ b/spirit/game/session/passives.py @@ -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