FEAT: add play targets to scripting engine

This commit is contained in:
Brandon Nguyen
2026-09-08 11:16:24 -07:00
parent 06943c34ca
commit 45b14992d0
10 changed files with 85 additions and 14 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}."

View File

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