From 45b14992d0f2cd2538e88c677ffc9ac236e9e18e Mon Sep 17 00:00:00 2001 From: Brandon Nguyen <58405975+Bratah123@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:16:24 -0700 Subject: [PATCH] FEAT: add play targets to scripting engine --- spirit/game/card_effects/trainers.py | 8 ++++++- spirit/game/content/definitions.py | 6 ++++- .../scripts/cards/SWSH2/BosssOrders_154.py | 4 +++- .../scripts/cards/SWSH2/BosssOrders_189.py | 4 +++- .../scripts/cards/SWSH2/BosssOrders_200.py | 4 +++- .../scripts/cards/SWSH45/BosssOrders_58.py | 4 +++- .../scripts/cards/SWSH9/BosssOrders_132.py | 4 +++- spirit/game/session/effects.py | 20 +++++++++++++--- spirit/game/session/game_session.py | 23 +++++++++++++++---- spirit/game/session/legal_actions.py | 22 ++++++++++++++++++ 10 files changed, 85 insertions(+), 14 deletions(-) diff --git a/spirit/game/card_effects/trainers.py b/spirit/game/card_effects/trainers.py index fdc5a0d..537d34e 100644 --- a/spirit/game/card_effects/trainers.py +++ b/spirit/game/card_effects/trainers.py @@ -54,6 +54,12 @@ def opponent_has_bench(board, player_id): return bool(opponent) and bool(_bench_pokemon(board, opponent)) +def opponent_bench_play_targets(board, player_id, card): + """Public bench targets for a single-target gust trainer.""" + opponent = _other_player(board, player_id) + return _bench_pokemon(board, opponent) if opponent else [] + + def player_has_bench(board, player_id): return bool(_bench_pokemon(board, player_id)) @@ -225,7 +231,7 @@ async def marnie(ctx): async def bosss_orders(ctx): """Switch 1 of the opponent's Benched Pokemon with their Active.""" - target = await ctx.choose_pokemon( + target = await ctx.choose_play_target( ctx.opponent_bench(), "Choose the opponent's new Active Pokémon" ) if target is not None: diff --git a/spirit/game/content/definitions.py b/spirit/game/content/definitions.py index a82150b..e9bbe0e 100644 --- a/spirit/game/content/definitions.py +++ b/spirit/game/content/definitions.py @@ -552,11 +552,15 @@ class TrainerCardDef(CardDefinition): searchable_by: Optional[List[str]] = None, subtypes: Optional[List[str]] = None, attributes: Optional[dict] = None, - foil: Optional[Foil] = None + foil: Optional[Foil] = None, + play_targets: Optional[Callable] = None, + play_target_prompt: str = "Choose a target", ): super().__init__(guid, key, name, collector_number, set_code, rarity, display_name, searchable_by, subtypes, attributes, foil) self.effect = effect self.condition = condition + self.play_targets = play_targets + self.play_target_prompt = play_target_prompt # Trainers have no PIE_ABILITIES slot; declared abilities register for # the session's trigger scans only (Dream Ball's ON_TAKEN_AS_PRIZE). self.abilities: List[Ability] = abilities or [] diff --git a/spirit/game/scripts/cards/SWSH2/BosssOrders_154.py b/spirit/game/scripts/cards/SWSH2/BosssOrders_154.py index 7834a31..f5dac2e 100644 --- a/spirit/game/scripts/cards/SWSH2/BosssOrders_154.py +++ b/spirit/game/scripts/cards/SWSH2/BosssOrders_154.py @@ -1,4 +1,4 @@ -from spirit.game.card_effects.trainers import bosss_orders, opponent_has_bench +from spirit.game.card_effects.trainers import bosss_orders, opponent_has_bench, opponent_bench_play_targets from spirit.game.data_utils import SupporterCardDef from spirit.game.attributes import Rarities @@ -13,5 +13,7 @@ card = SupporterCardDef( set_code="SWSH2", rarity=Rarities.RareHolo, effect=bosss_orders, + play_targets=opponent_bench_play_targets, + play_target_prompt="Choose the opponent's new Active Pokémon", condition=opponent_has_bench ) diff --git a/spirit/game/scripts/cards/SWSH2/BosssOrders_189.py b/spirit/game/scripts/cards/SWSH2/BosssOrders_189.py index e4ce311..172d193 100644 --- a/spirit/game/scripts/cards/SWSH2/BosssOrders_189.py +++ b/spirit/game/scripts/cards/SWSH2/BosssOrders_189.py @@ -1,4 +1,4 @@ -from spirit.game.card_effects.trainers import bosss_orders, opponent_has_bench +from spirit.game.card_effects.trainers import bosss_orders, opponent_has_bench, opponent_bench_play_targets from spirit.game.data_utils import SupporterCardDef from spirit.game.attributes import Rarities @@ -13,5 +13,7 @@ card = SupporterCardDef( set_code="SWSH2", rarity=Rarities.RareUltra, effect=bosss_orders, + play_targets=opponent_bench_play_targets, + play_target_prompt="Choose the opponent's new Active Pokémon", condition=opponent_has_bench ) diff --git a/spirit/game/scripts/cards/SWSH2/BosssOrders_200.py b/spirit/game/scripts/cards/SWSH2/BosssOrders_200.py index d263d16..8cf64a1 100644 --- a/spirit/game/scripts/cards/SWSH2/BosssOrders_200.py +++ b/spirit/game/scripts/cards/SWSH2/BosssOrders_200.py @@ -1,4 +1,4 @@ -from spirit.game.card_effects.trainers import bosss_orders, opponent_has_bench +from spirit.game.card_effects.trainers import bosss_orders, opponent_has_bench, opponent_bench_play_targets from spirit.game.data_utils import SupporterCardDef from spirit.game.attributes import Rarities @@ -13,5 +13,7 @@ card = SupporterCardDef( set_code="SWSH2", rarity=Rarities.RareRainbow, effect=bosss_orders, + play_targets=opponent_bench_play_targets, + play_target_prompt="Choose the opponent's new Active Pokémon", condition=opponent_has_bench ) diff --git a/spirit/game/scripts/cards/SWSH45/BosssOrders_58.py b/spirit/game/scripts/cards/SWSH45/BosssOrders_58.py index 8fbe331..6e460f8 100644 --- a/spirit/game/scripts/cards/SWSH45/BosssOrders_58.py +++ b/spirit/game/scripts/cards/SWSH45/BosssOrders_58.py @@ -1,4 +1,4 @@ -from spirit.game.card_effects.trainers import bosss_orders, opponent_has_bench +from spirit.game.card_effects.trainers import bosss_orders, opponent_has_bench, opponent_bench_play_targets from spirit.game.data_utils import SupporterCardDef from spirit.game.attributes import Rarities @@ -13,5 +13,7 @@ card = SupporterCardDef( set_code="SWSH45", rarity=Rarities.Rare, effect=bosss_orders, + play_targets=opponent_bench_play_targets, + play_target_prompt="Choose the opponent's new Active Pokémon", condition=opponent_has_bench ) diff --git a/spirit/game/scripts/cards/SWSH9/BosssOrders_132.py b/spirit/game/scripts/cards/SWSH9/BosssOrders_132.py index 2876ac2..365f933 100644 --- a/spirit/game/scripts/cards/SWSH9/BosssOrders_132.py +++ b/spirit/game/scripts/cards/SWSH9/BosssOrders_132.py @@ -1,4 +1,4 @@ -from spirit.game.card_effects.trainers import bosss_orders, opponent_has_bench +from spirit.game.card_effects.trainers import bosss_orders, opponent_has_bench, opponent_bench_play_targets from spirit.game.data_utils import SupporterCardDef from spirit.game.attributes import Rarities @@ -13,5 +13,7 @@ card = SupporterCardDef( set_code="SWSH9", rarity=Rarities.RareHolo, effect=bosss_orders, + play_targets=opponent_bench_play_targets, + play_target_prompt="Choose the opponent's new Active Pokémon", condition=opponent_has_bench ) diff --git a/spirit/game/session/effects.py b/spirit/game/session/effects.py index 7159260..cb42d80 100644 --- a/spirit/game/session/effects.py +++ b/spirit/game/session/effects.py @@ -86,7 +86,8 @@ class EffectContext: """ def __init__(self, session, player_id: str, source: BoardEntity, - ability: Optional[Ability], attached_to: Optional[PokemonEntity] = None): + ability: Optional[Ability], attached_to: Optional[PokemonEntity] = None, + play_target: Optional[BoardEntity] = None): self.session = session self.board = session.board_state self.game_id = session.game_id @@ -97,6 +98,8 @@ class EffectContext: self.ability = ability # For energy on-attach effects: the Pokemon the card just attached to. self.attached_to = attached_to + self.play_target = play_target + self._play_target_consumed = False self.knockouts: List[PokemonEntity] = [] # Extra prizes the attacker takes for knockouts this attack causes # (e.g. Stoutland V's Double Dip Fangs). @@ -1091,6 +1094,16 @@ class EffectContext: return False return True + async def choose_play_target( + self, candidates: Sequence[CardEntity], prompt: str, + ) -> Optional[CardEntity]: + """Uses the declared play target once, or prompts for an untargeted play.""" + if self.play_target is not None and not self._play_target_consumed: + self._play_target_consumed = True + return next((card for card in candidates if card is self.play_target), None) + picks = await self.choose_cards(candidates, 1, prompt=prompt) + return picks[0] if picks else None + async def choose_pokemon( self, candidates: Sequence[PokemonEntity], @@ -2433,7 +2446,8 @@ async def _send_ability_brackets(session, ctx: EffectContext, await session.enforce_bench_capacity() -async def resolve_trainer_effect(session, player_id: str, card) -> Optional[EffectContext]: +async def resolve_trainer_effect(session, player_id: str, card, + play_target=None) -> Optional[EffectContext]: """Runs a trainer card's scripted effect and returns its ctx (None when the card has no runnable effect). @@ -2461,7 +2475,7 @@ async def resolve_trainer_effect(session, player_id: str, card) -> Optional[Effe + "; card plays with no effect." ) return None - ctx = EffectContext(session, player_id, card, None) + ctx = EffectContext(session, player_id, card, None, play_target=play_target) ctx.is_trainer_effect = True await effect(ctx) return ctx diff --git a/spirit/game/session/game_session.py b/spirit/game/session/game_session.py index 481b950..1f2cb83 100644 --- a/spirit/game/session/game_session.py +++ b/spirit/game/session/game_session.py @@ -130,6 +130,7 @@ from .legal_actions import ( compute_legal_actions, copy_attack_choice_node, energy_provided_count, + trainer_play_target_ids, ) @@ -3727,7 +3728,11 @@ class GameSession: if len(selection) > 1 and isinstance(selection[1], (list, tuple)): for response in selection[1]: if isinstance(response, dict): - target_ids.extend(response.get("entityList") or []) + entities = response.get("entityList") or [] + if not isinstance(entities, list) or any( + not isinstance(entity_id, str) for entity_id in entities): + return None + target_ids.extend(entities) return entry, target_ids def _validated_target( @@ -3765,7 +3770,17 @@ class GameSession: elif description == ACTION_EVOLVE: await self._execute_evolve(player_id, card, entry, target_ids) elif description == ACTION_USE_TRAINER: - return await self._execute_play_trainer(player_id, card) + targets = trainer_play_target_ids(self.board_state, player_id, card) + play_target = None + if targets is not None: + if not targets or len(target_ids) > 1: + return False + if target_ids: + if (not isinstance(target_ids[0], str) or target_ids[0] not in targets + or self._validated_target(entry, target_ids) != target_ids[0]): + return False + play_target = self.board_state.get_entity(target_ids[0]) + return await self._execute_play_trainer(player_id, card, play_target) elif description == ACTION_PLAY_STADIUM: await self._execute_play_stadium(player_id, card) elif description == ACTION_USE_ABILITY: @@ -4740,7 +4755,7 @@ class GameSession: ) return incoming - async def _execute_play_trainer(self, player_id, card) -> bool: + async def _execute_play_trainer(self, player_id, card, play_target=None) -> 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") @@ -4776,7 +4791,7 @@ class GameSession: # Effect dialogs run after placement so both viewers see the card on # the trainer slot while the player decides. - ctx = await resolve_trainer_effect(self, player_id, card) + ctx = await resolve_trainer_effect(self, player_id, card, play_target=play_target) logging.info( f"[Session {self.game_id}] {self.players[player_id].screen_name} " f"played trainer {card.entity_id}." diff --git a/spirit/game/session/legal_actions.py b/spirit/game/session/legal_actions.py index 357c282..3ee3418 100644 --- a/spirit/game/session/legal_actions.py +++ b/spirit/game/session/legal_actions.py @@ -401,6 +401,17 @@ def _active_immobilized(board: BoardState, player_id: str) -> bool: return any(c in _IMMOBILIZING_CONDITIONS for c in (conditions or [])) +def trainer_play_target_ids(board: BoardState, player_id: str, card) -> Optional[List[str]]: + """Returns declared play targets, or None for a trainer without targeting.""" + selector = getattr(def_for(card.archetype_id), "play_targets", None) + if selector is None: + return None + return list(dict.fromkeys( + target.entity_id for target in selector(board, player_id, card) + if board.get_entity(target.entity_id) is target + )) + + def compute_legal_actions( board: BoardState, state: TurnState, @@ -489,16 +500,27 @@ def compute_legal_actions( if condition is not None \ and not trainer_condition_met(condition, board, player_id, card): continue + target_infos = [] + if trainer_type in (TrainerType.ITEM.value, TrainerType.SUPPORTER.value): + targets = trainer_play_target_ids(board, player_id, card) + if targets is not None: + if not targets: + continue + node = entity_list_target_info(targets, minimum_to_select=0, forced=False) + node["targetPrompt"] = {"id": definition.play_target_prompt} + target_infos = [node] if trainer_type == TrainerType.ITEM.value: entries.append(_target_map_entry( game_id, card.entity_id, action_id_for(card.entity_id, "item"), ACTION_USE_TRAINER, + target_infos, )) elif trainer_type == TrainerType.SUPPORTER.value: if not state.supporter_played and state.turn_number > 1: entries.append(_target_map_entry( game_id, card.entity_id, action_id_for(card.entity_id, "supporter"), ACTION_USE_TRAINER, + target_infos, )) elif trainer_type == TrainerType.STADIUM.value: if not state.stadium_played and not _same_stadium_in_play(board, card):